Yufok1 Claude Opus 4.8 commited on
Commit
616ae7b
·
1 Parent(s): 1a343cd

Checkpoint Devvit v0.0.26

Browse files

Rarity schema + granulated rarity depictions, and a new-plant NEW badge
so freshly bred seeds are not lost in the alphabetized seed list.

- Add src/shared/valuation.ts: 9-tier rarity schema (common -> primordial)
with bounded rarityIndex (0-100 collectibility, generation saturates so
top tiers stay scarce) and unbounded rarityScore (market valuation);
exported via shared/index.ts
- Depict tiers per seed card: rarity-* left-border colors + primordial
glow and matching rarity-chip text colors (index.css, game.tsx:1900)
- NEW badge for freshly bred seeds: newSeedIds diffed against
knownSeedIdsRef, keyed by seed id so it survives sorting, cleared by
acknowledgeNew; is-new pulse + new-badge (game.tsx, index.css)
- breeder: de-dupe offspring strain name against existing seeds
- lab/predictCross: summary_only + downsample to shrink payload
- color: add vibrance() helper; api: summary_only request field;
agentLink route adjustments

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

src/client/game.tsx CHANGED
@@ -6,8 +6,9 @@ import { connectRealtime } from '@devvit/web/client';
6
  import { StrictMode, useEffect, useMemo, useRef, useState } from 'react';
7
  import type { CSSProperties } from 'react';
8
  import { createRoot } from 'react-dom/client';
9
- import { colorFamily as colorFamilyFromHue } from '../shared/color';
10
  import { bloomScoreFromPalette } from '../shared/phenotype';
 
11
  import type {
12
  BusReceipt,
13
  BusSignal,
@@ -103,6 +104,7 @@ type InventoryRow = {
103
  budFamily: string;
104
  budPattern: string;
105
  parentText: string;
 
106
  };
107
  type SpriteSeed = Partial<SeedProfile & InventorySeed> & {
108
  baseImageName?: string;
@@ -648,9 +650,11 @@ function App() {
648
  const [hudOpen, setHudOpen] = useState(false);
649
  const [busy, setBusy] = useState(false);
650
  const [error, setError] = useState('');
 
651
 
652
  const seedsRef = useRef<InventorySeed[]>([]);
653
  const selectionRef = useRef<string[]>([]);
 
654
  const lastAgentEventIdRef = useRef<string | null>(null);
655
  const agentRefreshInFlightRef = useRef(false);
656
  const pendingAgentRefreshRef = useRef(false);
@@ -680,6 +684,23 @@ function App() {
680
  ]);
681
 
682
  const twoSlotSelection = selected.ids.slice(0, 2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
683
  setUsername(init.username);
684
  setPostId(init.postId);
685
  setSeeds(inventory.seeds);
@@ -796,7 +817,17 @@ function App() {
796
  selectionRef.current = twoSlotSelection;
797
  }
798
 
 
 
 
 
 
 
 
 
 
799
  function toggleSelection(seedId: string) {
 
800
  const current = selectionRef.current;
801
  const next = current.includes(seedId) ? current.filter((id) => id !== seedId) : [...current, seedId].slice(-2);
802
  void run('selection', async () => updateSelection(next));
@@ -817,15 +848,22 @@ function App() {
817
  await refreshAll();
818
  }
819
 
820
- async function growSeed(seedId: string) {
 
 
821
  await postJson(`/api/seed/${seedId}/grow`);
 
 
 
 
 
822
  await refreshAll();
823
  }
824
 
825
  async function growAll() {
826
  const immature = seedsRef.current.filter((seed) => seed.stage !== 'MATURE');
827
  for (const seed of immature) {
828
- await postJson(`/api/seed/${seed.id}/grow`);
829
  }
830
  await refreshAll();
831
  }
@@ -834,7 +872,7 @@ function App() {
834
  const currentSeeds = new Map(seedsRef.current.map((seed) => [seed.id, seed]));
835
  const selected = selectionRef.current.map((id) => currentSeeds.get(id)).filter((seed): seed is InventorySeed => Boolean(seed));
836
  for (const seed of selected) {
837
- if (seed.stage !== 'MATURE') await postJson(`/api/seed/${seed.id}/grow`);
838
  }
839
  await refreshAll();
840
  }
@@ -1003,6 +1041,7 @@ function App() {
1003
  onToggle={toggleSelection}
1004
  onRemoveSelection={removeSelection}
1005
  agentHighlightSeedId={agentUiIntent?.highlightSeedId}
 
1006
  />
1007
  ) : null}
1008
  {view === 'lab' ? (
@@ -1120,9 +1159,11 @@ function PlayView(props: {
1120
  onToggle: (seedId: string) => void;
1121
  onRemoveSelection: (seedId: string) => void;
1122
  agentHighlightSeedId?: string | undefined;
 
1123
  }) {
1124
  const generationCache = new Map<string, number>();
1125
  const [inventoryFilters, setInventoryFilters] = useState<InventoryFilters>(DEFAULT_INVENTORY_FILTERS);
 
1126
  const [labIntakeHidden, setLabIntakeHidden] = useState(() => readLocalStorage(LAB_INTAKE_HIDDEN_KEY) === '1');
1127
  const [chaseGoal, setChaseGoal] = useState<ChaseGoal>(() => {
1128
  const stored = readLocalStorage(LAB_INTAKE_GOAL_KEY);
@@ -1233,6 +1274,7 @@ function PlayView(props: {
1233
  onClone={props.onClone}
1234
  onRemove={props.onRemoveSelection}
1235
  highlighted={props.agentHighlightSeedId === props.selectedSeeds[0]?.id}
 
1236
  />
1237
  <ParentSlot
1238
  label="Parent B"
@@ -1243,6 +1285,7 @@ function PlayView(props: {
1243
  onClone={props.onClone}
1244
  onRemove={props.onRemoveSelection}
1245
  highlighted={props.agentHighlightSeedId === props.selectedSeeds[1]?.id}
 
1246
  />
1247
  </div>
1248
  <PredictionPanel prediction={props.prediction} />
@@ -1281,6 +1324,9 @@ function PlayView(props: {
1281
  onToggle={() => props.onToggle(row.seed.id)}
1282
  onClone={() => props.onClone(row.seed.id)}
1283
  highlighted={props.agentHighlightSeedId === row.seed.id}
 
 
 
1284
  />
1285
  ))}
1286
  </div>
@@ -1289,6 +1335,64 @@ function PlayView(props: {
1289
  )}
1290
  {props.seeds.length && !filteredRows.length ? <div className="empty-state">No specimens match the active lab filters.</div> : null}
1291
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1292
  </div>
1293
  );
1294
  }
@@ -1496,6 +1600,7 @@ function ParentSlot({
1496
  onClone,
1497
  onRemove,
1498
  highlighted,
 
1499
  }: {
1500
  label: string;
1501
  seed: InventorySeed | undefined;
@@ -1505,6 +1610,7 @@ function ParentSlot({
1505
  onClone: (seedId?: string) => void;
1506
  onRemove: (seedId: string) => void;
1507
  highlighted?: boolean;
 
1508
  }) {
1509
  if (!seed) {
1510
  return (
@@ -1534,7 +1640,7 @@ function ParentSlot({
1534
  <Stat label="CBD" value={`${fmt(seed.cbd)}%`} />
1535
  <Stat label="Yield" value={`${fmt(seed.yield)}g`} />
1536
  <Stat label="Grow" value={`${fmt(seed.grow_time)}d`} />
1537
- <Stat label="Attempts" value={`${seed.attempts_used}/${seed.max_attempts}`} />
1538
  <Stat label="Can breed" value={seed.can_attempt ? 'yes' : 'no'} />
1539
  </div>
1540
  </div>
@@ -1556,6 +1662,7 @@ function ParentSlot({
1556
  <div className="slot-actions">
1557
  <button disabled={seed.stage === 'MATURE'} onClick={() => onGrow(seed.id)}>Grow</button>
1558
  <button disabled={!seed.can_attempt} onClick={() => onClone(seed.id)}>Clone</button>
 
1559
  <button onClick={() => onRemove(seed.id)}>Remove</button>
1560
  </div>
1561
  </article>
@@ -1574,6 +1681,9 @@ function SeedCard({
1574
  onToggle,
1575
  onClone,
1576
  highlighted,
 
 
 
1577
  }: {
1578
  seed: InventorySeed;
1579
  selected: boolean;
@@ -1586,9 +1696,14 @@ function SeedCard({
1586
  onToggle: () => void;
1587
  onClone: () => void;
1588
  highlighted?: boolean;
 
 
 
1589
  }) {
1590
  return (
1591
- <article className={`seed-card${selected ? ' selected' : ''}${highlighted ? ' agent-highlight' : ''}`}>
 
 
1592
  <div className="seed-head">
1593
  <GeneticSprite className="seed-img" seed={seed} />
1594
  <div>
@@ -1597,9 +1712,10 @@ function SeedCard({
1597
  </div>
1598
  </div>
1599
  <div className="seed-meta-line">
 
1600
  <span>{seed.is_starter ? 'starter' : 'derived'}</span>
1601
  <span>{shortId(seed.id)}</span>
1602
- <span>{seed.attempts_used}/{seed.max_attempts} attempts</span>
1603
  <span>Bloom {fmt(bloomScore, 1)}</span>
1604
  <span>Vol {fmt(volatility, 2)}</span>
1605
  <span>{budPattern}</span>
@@ -1780,15 +1896,28 @@ function inventoryRow(seed: InventorySeed, generation: number, byId: Map<string,
1780
  const stability = meanSeedStability(seed);
1781
  const budPalette = spritePalette(seed, 'bud');
1782
  const parentText = seed.lineage.map((parentId) => (parentId ? byId.get(parentId)?.name ?? shortId(parentId) : 'founder')).join(' / ');
 
 
 
 
 
 
 
 
 
 
 
 
1783
  return {
1784
  seed,
1785
  generation,
1786
  stability,
1787
  volatility: Math.round((1 - stability) * 1000) / 1000,
1788
- bloom: bloomScoreFromPalette(budPalette, (seed.bud_pattern ?? seed.budPattern ?? 'solid') as InventorySeed['budPattern']),
1789
  budFamily: colorFamilyFromHue(hueFromRgb(seed.bud_color)),
1790
  budPattern: String(seed.bud_pattern ?? seed.budPattern ?? 'solid'),
1791
  parentText,
 
1792
  };
1793
  }
1794
 
@@ -2207,7 +2336,7 @@ function DeckView({
2207
  <GeneticSprite seed={seed} />
2208
  <div>
2209
  <b>{seed.name}</b>
2210
- <span>{seed.stage} / THC {fmt(seed.thc)} / Yield {fmt(seed.yield)} / attempts {seed.attempts_used}/{seed.max_attempts}</span>
2211
  </div>
2212
  <span className="swatch" style={{ background: rgb(seed.bud_color) }} />
2213
  </div>
@@ -2494,7 +2623,7 @@ function MarketView({
2494
  <GeneticSprite seed={seed} />
2495
  <div>
2496
  <b>{seed.name}</b>
2497
- <span>{seed.stage} / THC {fmt(seed.thc)} / Yield {fmt(seed.yield)} / attempts {seed.attempts_used}/{seed.max_attempts}</span>
2498
  </div>
2499
  <button disabled={busy || !seed.can_attempt} onClick={() => onListClone(seed.id)}>List clone</button>
2500
  <button className="danger" disabled={busy} onClick={() => onListPlant(seed.id)}>List plant</button>
 
6
  import { StrictMode, useEffect, useMemo, useRef, useState } from 'react';
7
  import type { CSSProperties } from 'react';
8
  import { createRoot } from 'react-dom/client';
9
+ import { colorFamily as colorFamilyFromHue, vibrance } from '../shared/color';
10
  import { bloomScoreFromPalette } from '../shared/phenotype';
11
+ import { rarityIndex, rarityTier, type RarityTier } from '../shared/valuation';
12
  import type {
13
  BusReceipt,
14
  BusSignal,
 
104
  budFamily: string;
105
  budPattern: string;
106
  parentText: string;
107
+ rarity: RarityTier;
108
  };
109
  type SpriteSeed = Partial<SeedProfile & InventorySeed> & {
110
  baseImageName?: string;
 
650
  const [hudOpen, setHudOpen] = useState(false);
651
  const [busy, setBusy] = useState(false);
652
  const [error, setError] = useState('');
653
+ const [newSeedIds, setNewSeedIds] = useState<Set<string>>(new Set());
654
 
655
  const seedsRef = useRef<InventorySeed[]>([]);
656
  const selectionRef = useRef<string[]>([]);
657
+ const knownSeedIdsRef = useRef<Set<string> | null>(null);
658
  const lastAgentEventIdRef = useRef<string | null>(null);
659
  const agentRefreshInFlightRef = useRef(false);
660
  const pendingAgentRefreshRef = useRef(false);
 
684
  ]);
685
 
686
  const twoSlotSelection = selected.ids.slice(0, 2);
687
+ const incomingIds = inventory.seeds.map((seed) => seed.id);
688
+ const incomingSet = new Set(incomingIds);
689
+ const known = knownSeedIdsRef.current;
690
+ if (known === null) {
691
+ // First load establishes the baseline; nothing is "new" on arrival.
692
+ knownSeedIdsRef.current = incomingSet;
693
+ } else {
694
+ const fresh = incomingIds.filter((id) => !known.has(id));
695
+ setNewSeedIds((prev) => {
696
+ const next = new Set<string>();
697
+ for (const id of prev) if (incomingSet.has(id)) next.add(id);
698
+ for (const id of fresh) next.add(id);
699
+ return next;
700
+ });
701
+ knownSeedIdsRef.current = incomingSet;
702
+ }
703
+
704
  setUsername(init.username);
705
  setPostId(init.postId);
706
  setSeeds(inventory.seeds);
 
817
  selectionRef.current = twoSlotSelection;
818
  }
819
 
820
+ function acknowledgeNew(seedId: string) {
821
+ setNewSeedIds((prev) => {
822
+ if (!prev.has(seedId)) return prev;
823
+ const next = new Set(prev);
824
+ next.delete(seedId);
825
+ return next;
826
+ });
827
+ }
828
+
829
  function toggleSelection(seedId: string) {
830
+ acknowledgeNew(seedId);
831
  const current = selectionRef.current;
832
  const next = current.includes(seedId) ? current.filter((id) => id !== seedId) : [...current, seedId].slice(-2);
833
  void run('selection', async () => updateSelection(next));
 
848
  await refreshAll();
849
  }
850
 
851
+ // SEED -> SEEDLING -> MATURE is a fixed two-step ladder with no time gate, so
852
+ // growing advances straight to maturity. Grow on a MATURE plant is a server no-op.
853
+ async function growToMature(seedId: string) {
854
  await postJson(`/api/seed/${seedId}/grow`);
855
+ await postJson(`/api/seed/${seedId}/grow`);
856
+ }
857
+
858
+ async function growSeed(seedId: string) {
859
+ await growToMature(seedId);
860
  await refreshAll();
861
  }
862
 
863
  async function growAll() {
864
  const immature = seedsRef.current.filter((seed) => seed.stage !== 'MATURE');
865
  for (const seed of immature) {
866
+ await growToMature(seed.id);
867
  }
868
  await refreshAll();
869
  }
 
872
  const currentSeeds = new Map(seedsRef.current.map((seed) => [seed.id, seed]));
873
  const selected = selectionRef.current.map((id) => currentSeeds.get(id)).filter((seed): seed is InventorySeed => Boolean(seed));
874
  for (const seed of selected) {
875
+ if (seed.stage !== 'MATURE') await growToMature(seed.id);
876
  }
877
  await refreshAll();
878
  }
 
1041
  onToggle={toggleSelection}
1042
  onRemoveSelection={removeSelection}
1043
  agentHighlightSeedId={agentUiIntent?.highlightSeedId}
1044
+ newSeedIds={newSeedIds}
1045
  />
1046
  ) : null}
1047
  {view === 'lab' ? (
 
1159
  onToggle: (seedId: string) => void;
1160
  onRemoveSelection: (seedId: string) => void;
1161
  agentHighlightSeedId?: string | undefined;
1162
+ newSeedIds: Set<string>;
1163
  }) {
1164
  const generationCache = new Map<string, number>();
1165
  const [inventoryFilters, setInventoryFilters] = useState<InventoryFilters>(DEFAULT_INVENTORY_FILTERS);
1166
+ const [maximizedSeed, setMaximizedSeed] = useState<InventorySeed | null>(null);
1167
  const [labIntakeHidden, setLabIntakeHidden] = useState(() => readLocalStorage(LAB_INTAKE_HIDDEN_KEY) === '1');
1168
  const [chaseGoal, setChaseGoal] = useState<ChaseGoal>(() => {
1169
  const stored = readLocalStorage(LAB_INTAKE_GOAL_KEY);
 
1274
  onClone={props.onClone}
1275
  onRemove={props.onRemoveSelection}
1276
  highlighted={props.agentHighlightSeedId === props.selectedSeeds[0]?.id}
1277
+ onMaximize={() => props.selectedSeeds[0] && setMaximizedSeed(props.selectedSeeds[0])}
1278
  />
1279
  <ParentSlot
1280
  label="Parent B"
 
1285
  onClone={props.onClone}
1286
  onRemove={props.onRemoveSelection}
1287
  highlighted={props.agentHighlightSeedId === props.selectedSeeds[1]?.id}
1288
+ onMaximize={() => props.selectedSeeds[1] && setMaximizedSeed(props.selectedSeeds[1])}
1289
  />
1290
  </div>
1291
  <PredictionPanel prediction={props.prediction} />
 
1324
  onToggle={() => props.onToggle(row.seed.id)}
1325
  onClone={() => props.onClone(row.seed.id)}
1326
  highlighted={props.agentHighlightSeedId === row.seed.id}
1327
+ rarity={row.rarity}
1328
+ isNew={props.newSeedIds.has(row.seed.id)}
1329
+ onMaximize={() => setMaximizedSeed(row.seed)}
1330
  />
1331
  ))}
1332
  </div>
 
1335
  )}
1336
  {props.seeds.length && !filteredRows.length ? <div className="empty-state">No specimens match the active lab filters.</div> : null}
1337
  </section>
1338
+ {maximizedSeed ? <PlantModal seed={maximizedSeed} byId={props.byId} onClose={() => setMaximizedSeed(null)} /> : null}
1339
+ </div>
1340
+ );
1341
+ }
1342
+
1343
+ function PlantModal({ seed, byId, onClose }: { seed: InventorySeed; byId: Map<string, InventorySeed>; onClose: () => void }) {
1344
+ useEffect(() => {
1345
+ function onKey(event: KeyboardEvent) {
1346
+ if (event.key === 'Escape') onClose();
1347
+ }
1348
+ window.addEventListener('keydown', onKey);
1349
+ return () => window.removeEventListener('keydown', onKey);
1350
+ }, [onClose]);
1351
+ const generation = generationOf(seed, byId);
1352
+ const row = inventoryRow(seed, generation, byId);
1353
+ const parents = seed.lineage.map((parentId) => (parentId ? byId.get(parentId)?.name ?? shortId(parentId) : 'founder'));
1354
+ const cloneRoom = Math.max(0, seed.max_attempts - seed.attempts_used);
1355
+ return (
1356
+ <div className="plant-modal-backdrop" onClick={onClose}>
1357
+ <div className={`plant-modal rarity-${row.rarity}`} role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
1358
+ <div className="plant-modal-bar">
1359
+ <div className="plant-modal-title">
1360
+ <h2>{seed.name}</h2>
1361
+ <span className={`rarity-chip rarity-${row.rarity}`}>{row.rarity}</span>
1362
+ </div>
1363
+ <button className="plant-modal-close" type="button" onClick={onClose}>Close</button>
1364
+ </div>
1365
+ <div className="plant-modal-body">
1366
+ <div className="plant-modal-stage">
1367
+ <GeneticSprite className="plant-modal-img" seed={seed} stage="mature" />
1368
+ <div className="plant-modal-swatches">
1369
+ <div className="swatch-line"><span className="swatch" style={{ background: rgb(seed.bud_color) }} /><span>Bud {seed.bud_color.join(', ')} / hue {hueFromRgb(seed.bud_color)}</span></div>
1370
+ <PaletteStrip colors={seed.bud_palette} />
1371
+ <div className="swatch-line"><span className="swatch" style={{ background: rgb(seed.leaf_color) }} /><span>Leaf {seed.leaf_color.join(', ')} / hue {hueFromRgb(seed.leaf_color)}</span></div>
1372
+ <PaletteStrip colors={seed.leaf_palette} />
1373
+ </div>
1374
+ </div>
1375
+ <div className="plant-modal-info">
1376
+ <p className="plant-modal-sub">{seed.type} / F{generation} / {seed.stage} / {seed.is_starter ? 'starter' : 'derived'} / {row.budPattern}</p>
1377
+ <div className="stat-grid">
1378
+ <Stat label="THC" value={`${fmt(seed.thc)}%`} />
1379
+ <Stat label="CBD" value={`${fmt(seed.cbd)}%`} />
1380
+ <Stat label="Yield" value={`${fmt(seed.yield)}g`} />
1381
+ <Stat label="Grow" value={`${fmt(seed.grow_time)}d`} />
1382
+ <Stat label="Bloom" value={fmt(row.bloom.score, 1)} />
1383
+ <Stat label="Volatility" value={fmt(row.volatility, 2)} />
1384
+ <Stat label="Attempts" value={`${cloneRoom} left`} />
1385
+ <Stat label="Colors" value={row.bloom.colorCount} />
1386
+ </div>
1387
+ <TraitMatrix seed={seed} />
1388
+ <div className="lineage-box">
1389
+ <div><b>ID</b><span>{seed.id}</span></div>
1390
+ <div><b>Parents</b><span>{parents.join(' / ')}</span></div>
1391
+ <div><b>Description</b><span>{seed.description || 'No description'}</span></div>
1392
+ </div>
1393
+ </div>
1394
+ </div>
1395
+ </div>
1396
  </div>
1397
  );
1398
  }
 
1600
  onClone,
1601
  onRemove,
1602
  highlighted,
1603
+ onMaximize,
1604
  }: {
1605
  label: string;
1606
  seed: InventorySeed | undefined;
 
1610
  onClone: (seedId?: string) => void;
1611
  onRemove: (seedId: string) => void;
1612
  highlighted?: boolean;
1613
+ onMaximize?: () => void;
1614
  }) {
1615
  if (!seed) {
1616
  return (
 
1640
  <Stat label="CBD" value={`${fmt(seed.cbd)}%`} />
1641
  <Stat label="Yield" value={`${fmt(seed.yield)}g`} />
1642
  <Stat label="Grow" value={`${fmt(seed.grow_time)}d`} />
1643
+ <Stat label="Attempts" value={`${Math.max(0, seed.max_attempts - seed.attempts_used)} left`} />
1644
  <Stat label="Can breed" value={seed.can_attempt ? 'yes' : 'no'} />
1645
  </div>
1646
  </div>
 
1662
  <div className="slot-actions">
1663
  <button disabled={seed.stage === 'MATURE'} onClick={() => onGrow(seed.id)}>Grow</button>
1664
  <button disabled={!seed.can_attempt} onClick={() => onClone(seed.id)}>Clone</button>
1665
+ <button onClick={onMaximize}>Expand</button>
1666
  <button onClick={() => onRemove(seed.id)}>Remove</button>
1667
  </div>
1668
  </article>
 
1681
  onToggle,
1682
  onClone,
1683
  highlighted,
1684
+ rarity,
1685
+ isNew,
1686
+ onMaximize,
1687
  }: {
1688
  seed: InventorySeed;
1689
  selected: boolean;
 
1696
  onToggle: () => void;
1697
  onClone: () => void;
1698
  highlighted?: boolean;
1699
+ rarity: RarityTier;
1700
+ isNew?: boolean;
1701
+ onMaximize: () => void;
1702
  }) {
1703
  return (
1704
+ <article className={`seed-card rarity-${rarity}${isNew ? ' is-new' : ''}${selected ? ' selected' : ''}${highlighted ? ' agent-highlight' : ''}`}>
1705
+ {isNew ? <span className="new-badge">NEW</span> : null}
1706
+ <button className="card-expand" type="button" title="Full view" aria-label="Open full view" onClick={onMaximize}>Expand</button>
1707
  <div className="seed-head">
1708
  <GeneticSprite className="seed-img" seed={seed} />
1709
  <div>
 
1712
  </div>
1713
  </div>
1714
  <div className="seed-meta-line">
1715
+ <span className={`rarity-chip rarity-${rarity}`}>{rarity}</span>
1716
  <span>{seed.is_starter ? 'starter' : 'derived'}</span>
1717
  <span>{shortId(seed.id)}</span>
1718
+ <span>{Math.max(0, seed.max_attempts - seed.attempts_used)} left</span>
1719
  <span>Bloom {fmt(bloomScore, 1)}</span>
1720
  <span>Vol {fmt(volatility, 2)}</span>
1721
  <span>{budPattern}</span>
 
1896
  const stability = meanSeedStability(seed);
1897
  const budPalette = spritePalette(seed, 'bud');
1898
  const parentText = seed.lineage.map((parentId) => (parentId ? byId.get(parentId)?.name ?? shortId(parentId) : 'founder')).join(' / ');
1899
+ const bloom = bloomScoreFromPalette(budPalette, (seed.bud_pattern ?? seed.budPattern ?? 'solid') as InventorySeed['budPattern']);
1900
+ const rarity = rarityTier(rarityIndex({
1901
+ thc: seed.thc,
1902
+ cbd: seed.cbd,
1903
+ yield: seed.yield,
1904
+ growTime: seed.grow_time,
1905
+ stability,
1906
+ generation,
1907
+ vibrance: vibrance(seed.bud_color),
1908
+ bloom: bloom.score,
1909
+ canAttempt: seed.can_attempt,
1910
+ }));
1911
  return {
1912
  seed,
1913
  generation,
1914
  stability,
1915
  volatility: Math.round((1 - stability) * 1000) / 1000,
1916
+ bloom,
1917
  budFamily: colorFamilyFromHue(hueFromRgb(seed.bud_color)),
1918
  budPattern: String(seed.bud_pattern ?? seed.budPattern ?? 'solid'),
1919
  parentText,
1920
+ rarity,
1921
  };
1922
  }
1923
 
 
2336
  <GeneticSprite seed={seed} />
2337
  <div>
2338
  <b>{seed.name}</b>
2339
+ <span>{seed.stage} / THC {fmt(seed.thc)} / Yield {fmt(seed.yield)} / {Math.max(0, seed.max_attempts - seed.attempts_used)} attempts left</span>
2340
  </div>
2341
  <span className="swatch" style={{ background: rgb(seed.bud_color) }} />
2342
  </div>
 
2623
  <GeneticSprite seed={seed} />
2624
  <div>
2625
  <b>{seed.name}</b>
2626
+ <span>{seed.stage} / THC {fmt(seed.thc)} / Yield {fmt(seed.yield)} / {Math.max(0, seed.max_attempts - seed.attempts_used)} attempts left</span>
2627
  </div>
2628
  <button disabled={busy || !seed.can_attempt} onClick={() => onListClone(seed.id)}>List clone</button>
2629
  <button className="danger" disabled={busy} onClick={() => onListPlant(seed.id)}>List plant</button>
src/client/index.css CHANGED
@@ -251,8 +251,111 @@ code, pre { font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mon
251
  .chip { border: 1px solid var(--green-2); background: #182318; color: #dff5d8; border-radius: 999px; padding: 4px 8px; font-size: 12px; }
252
  .chip.alt { border-color: #315675; background: #15202a; color: #d7ecff; }
253
  .registry-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(235px, 1fr)); gap: 10px; max-height: calc(100vh - 238px); overflow: auto; padding-right: 2px; }
254
- .seed-card { display: grid; grid-template-rows: auto auto auto auto 1fr auto; gap: 8px; border: 1px solid var(--line); border-radius: 8px; background: #151a1b; padding: 10px; min-height: 288px; }
255
  .seed-card.selected { border-color: var(--green); box-shadow: 0 0 0 1px rgba(139, 211, 111, 0.45); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  .seed-head { display: grid; grid-template-columns: 60px minmax(0, 1fr); gap: 10px; align-items: center; }
257
  .seed-img { width: 60px; height: 60px; object-fit: contain; image-rendering: pixelated; border: 1px solid var(--line); border-radius: 7px; background: #0b0e0f; }
258
  .seed-head h3 { margin: 0; font-size: 15px; line-height: 1.18; overflow-wrap: anywhere; }
 
251
  .chip { border: 1px solid var(--green-2); background: #182318; color: #dff5d8; border-radius: 999px; padding: 4px 8px; font-size: 12px; }
252
  .chip.alt { border-color: #315675; background: #15202a; color: #d7ecff; }
253
  .registry-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(235px, 1fr)); gap: 10px; max-height: calc(100vh - 238px); overflow: auto; padding-right: 2px; }
254
+ .seed-card { position: relative; display: grid; grid-template-rows: auto auto auto auto 1fr auto; gap: 8px; border: 1px solid var(--line); border-radius: 8px; background: #151a1b; padding: 10px; min-height: 288px; }
255
  .seed-card.selected { border-color: var(--green); box-shadow: 0 0 0 1px rgba(139, 211, 111, 0.45); }
256
+
257
+ /* Rarity ladder edge (9 tiers, ascending heat). common stays edgeless to keep quiet. */
258
+ .seed-card.rarity-uncommon { border-left: 3px solid #6f9e5a; }
259
+ .seed-card.rarity-notable { border-left: 3px solid var(--blue); }
260
+ .seed-card.rarity-rare { border-left: 3px solid var(--cyan); }
261
+ .seed-card.rarity-elite { border-left: 3px solid var(--violet); }
262
+ .seed-card.rarity-exotic { border-left: 3px solid #e07bc4; }
263
+ .seed-card.rarity-legendary { border-left: 3px solid var(--amber); }
264
+ .seed-card.rarity-mythic { border-left: 3px solid #f0873f; }
265
+ .seed-card.rarity-primordial { border-left: 3px solid #ffd76a; box-shadow: 0 0 0 1px rgba(255, 215, 106, 0.35), 0 0 18px rgba(255, 215, 106, 0.22); }
266
+
267
+ .rarity-chip { text-transform: capitalize; color: var(--muted); }
268
+ .rarity-chip.rarity-uncommon { color: #8fca74; border-color: rgba(111, 158, 90, 0.5); }
269
+ .rarity-chip.rarity-notable { color: var(--blue); border-color: rgba(119, 183, 239, 0.5); }
270
+ .rarity-chip.rarity-rare { color: var(--cyan); border-color: rgba(101, 210, 208, 0.5); }
271
+ .rarity-chip.rarity-elite { color: var(--violet); border-color: rgba(168, 138, 232, 0.5); }
272
+ .rarity-chip.rarity-exotic { color: #e07bc4; border-color: rgba(224, 123, 196, 0.5); }
273
+ .rarity-chip.rarity-legendary { color: var(--amber); border-color: rgba(229, 190, 102, 0.6); }
274
+ .rarity-chip.rarity-mythic { color: #f0873f; border-color: rgba(240, 135, 63, 0.6); }
275
+ .rarity-chip.rarity-primordial { color: #ffd76a; border-color: rgba(255, 215, 106, 0.7); }
276
+
277
+ /* Session-new plants: badge + pulse so fresh breeds are findable in the alphabetized list. */
278
+ .new-badge {
279
+ position: absolute;
280
+ top: -7px;
281
+ right: -6px;
282
+ z-index: 3;
283
+ padding: 2px 7px;
284
+ font-size: 10px;
285
+ font-weight: 700;
286
+ letter-spacing: 0.06em;
287
+ color: #0d0f12;
288
+ background: var(--green);
289
+ border-radius: 999px;
290
+ box-shadow: 0 1px 6px rgba(139, 211, 111, 0.5);
291
+ }
292
+ .seed-card.is-new { border-color: var(--green); animation: newPulse 1.4s ease-in-out infinite alternate; }
293
+ @keyframes newPulse {
294
+ from { box-shadow: 0 0 0 1px rgba(139, 211, 111, 0.3); }
295
+ to { box-shadow: 0 0 0 1px rgba(139, 211, 111, 0.72), 0 0 16px rgba(139, 211, 111, 0.22); }
296
+ }
297
+
298
+ /* Per-card Expand affordance -> full-window plant view */
299
+ .card-expand {
300
+ position: absolute;
301
+ top: 6px;
302
+ left: 6px;
303
+ z-index: 2;
304
+ min-height: 0;
305
+ padding: 2px 8px;
306
+ font-size: 10px;
307
+ line-height: 1.5;
308
+ border-radius: 999px;
309
+ background: rgba(13, 15, 18, 0.72);
310
+ border-color: var(--line-soft);
311
+ color: var(--muted);
312
+ opacity: 0.6;
313
+ transition: opacity 0.12s ease, color 0.12s ease, border-color 0.12s ease;
314
+ }
315
+ .seed-card:hover .card-expand, .seed-card:focus-within .card-expand { opacity: 1; }
316
+ .card-expand:hover:not(:disabled) { color: var(--text); border-color: var(--green); background: #202724; }
317
+
318
+ .plant-modal-backdrop {
319
+ position: fixed;
320
+ inset: 0;
321
+ z-index: 120;
322
+ display: flex;
323
+ align-items: center;
324
+ justify-content: center;
325
+ padding: 16px;
326
+ background: rgba(6, 8, 9, 0.78);
327
+ backdrop-filter: blur(3px);
328
+ overflow: auto;
329
+ }
330
+ .plant-modal {
331
+ width: min(940px, 100%);
332
+ max-height: 92vh;
333
+ overflow: auto;
334
+ display: grid;
335
+ gap: 12px;
336
+ padding: 16px;
337
+ border: 1px solid var(--line);
338
+ border-radius: 12px;
339
+ background: var(--panel);
340
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
341
+ }
342
+ .plant-modal-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
343
+ .plant-modal-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
344
+ .plant-modal-title h2 { margin: 0; font-size: 22px; overflow-wrap: anywhere; }
345
+ .plant-modal-close { min-height: 30px; }
346
+ .plant-modal-body { display: grid; grid-template-columns: minmax(0, 360px) minmax(0, 1fr); gap: 16px; align-items: start; }
347
+ .plant-modal-stage { display: grid; gap: 10px; }
348
+ .plant-modal-img { width: 100%; aspect-ratio: 1; object-fit: contain; image-rendering: pixelated; border: 1px solid var(--line); border-radius: 10px; background: #0b0e0f; }
349
+ .plant-modal-swatches { display: grid; gap: 6px; }
350
+ .plant-modal-info { display: grid; gap: 10px; align-content: start; }
351
+ .plant-modal-sub { margin: 0; color: var(--muted); font-size: 13px; }
352
+ .plant-modal .stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
353
+ .plant-modal .trait-matrix { max-height: none; }
354
+ @media (max-width: 680px) {
355
+ .plant-modal-body { grid-template-columns: 1fr; }
356
+ .plant-modal-img { max-width: 320px; justify-self: center; }
357
+ .plant-modal .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
358
+ }
359
  .seed-head { display: grid; grid-template-columns: 60px minmax(0, 1fr); gap: 10px; align-items: center; }
360
  .seed-img { width: 60px; height: 60px; object-fit: contain; image-rendering: pixelated; border: 1px solid var(--line); border-radius: 7px; background: #0b0e0f; }
361
  .seed-head h3 { margin: 0; font-size: 15px; line-height: 1.18; overflow-wrap: anywhere; }
src/server/routes/agentLink.ts CHANGED
@@ -17,9 +17,13 @@ import {
17
  newId,
18
  nowIso,
19
  predictCross,
 
 
 
20
  replaceSeeds,
 
21
  } from '../../shared';
22
- import type { BusReceipt, GameState, SeedProfile } from '../../shared';
23
 
24
  type RuntimeIdentity = { postId: string; username: string };
25
  const BROKER_USERNAME = 'Maryjane_Broker';
@@ -71,7 +75,7 @@ type InventorySeed = SeedProfile & {
71
 
72
  type SeedValuation = {
73
  score: number;
74
- tier: 'common' | 'notable' | 'rare' | 'elite' | 'legendary';
75
  commonCloneEquivalent: number;
76
  leaderboardEvidence: Array<{ metric: string; rank: number; score: number }>;
77
  traits: {
@@ -252,7 +256,7 @@ const AGENT_LINK_TOOLS: AgentLinkTool[] = [
252
  { name: 'grow', label: 'Grow specimen', scope: 'garden', risk: 'mutating', description: 'Advance one specimen by one growth stage.', input: { seed_id: { type: 'string' } } },
253
  { name: 'grow_selected', label: 'Grow selected', scope: 'garden', risk: 'mutating', description: 'Advance each selected on-deck specimen by one stage.', input: {} },
254
  { name: 'grow_all', label: 'Grow all', scope: 'garden', risk: 'mutating', description: 'Advance every immature specimen by one stage.', input: {} },
255
- { name: 'predict_cross', label: 'Predict cross', scope: 'breed', risk: 'inspect', description: 'Run the cross predictor for two parents or current on-deck plants.', input: { parent1_id: { type: 'string' }, parent2_id: { type: 'string' }, n: { type: 'integer' } } },
256
  { name: 'breed', label: 'Breed parents', scope: 'breed', risk: 'mutating', description: 'Breed two parents or the current on-deck plants.', input: { parent1_id: { type: 'string' }, parent2_id: { type: 'string' } } },
257
  { name: 'clone', label: 'Clone plant', scope: 'garden', risk: 'mutating', description: 'Clone one plant into the owner inventory.', input: { seed_id: { type: 'string' } } },
258
  { name: 'inspect_market', label: 'Inspect exchange', scope: 'market', risk: 'inspect', description: 'Read clone exchange listings, offers, sequence candidates, and order-book stats.', input: {} },
@@ -432,40 +436,37 @@ function addMissingStarters(state: GameState): GameState {
432
  return missing.length ? replaceSeeds(state, [...state.seeds, ...missing]) : state;
433
  }
434
 
435
- function vibrance(color: SeedProfile['budColor']): number {
436
- const [red, green, blue] = color;
437
- return Math.round(((Math.max(red, green, blue) - Math.min(red, green, blue)) / 255) * 1000) / 10;
438
- }
439
-
440
- function valuationTier(score: number): SeedValuation['tier'] {
441
- if (score >= 85) return 'legendary';
442
- if (score >= 70) return 'elite';
443
- if (score >= 55) return 'rare';
444
- if (score >= 38) return 'notable';
445
- return 'common';
446
- }
447
-
448
  function valuationFromRecord(record: LabRecord, leaderboard: LeaderboardEntry[]): SeedValuation {
449
  const vibranceScore = vibrance(record.budColor);
450
  const bloomScore = record.Bloom;
451
- const score = Math.round((
452
- record.THC * 1.35 +
453
- record.CBD * 0.55 +
454
- record.Yield * 0.18 +
455
- Math.max(0, 90 - record.GrowTime) * 0.18 +
456
- record.Stability * 18 +
457
- record.generation * 4 +
458
- vibranceScore * 0.16 +
459
- bloomScore * 0.14 +
460
- (record.canAttempt ? 5 : 0)
461
- ) * 10) / 10;
462
  const evidence = leaderboard
463
  .filter((entry) => entry.seedId === record.germplasmDbId && entry.rank !== undefined && entry.rank <= 10)
464
  .map((entry) => ({ metric: entry.metric, rank: entry.rank ?? 0, score: entry.score }))
465
  .slice(0, 6);
466
  return {
467
  score,
468
- tier: valuationTier(score),
 
 
 
 
 
 
 
 
 
 
469
  commonCloneEquivalent: Math.max(1, Math.min(12, Math.round(score / 18))),
470
  leaderboardEvidence: evidence,
471
  traits: {
@@ -1431,7 +1432,8 @@ async function runAgentLinkTool(id: RuntimeIdentity, session: AgentLinkSession,
1431
  const seed = findSeed(state, seedId);
1432
  if (!seed) throw new Error('Seed not found');
1433
  const next = await saveState(id, replaceSeeds(state, state.seeds.map((item) => item.seedId === seedId ? { ...item, growthStage: advanceGrowthStage(item.growthStage) } : item)));
1434
- result = { seed: findSeed(next, seedId) };
 
1435
  } else if (toolName === 'grow_selected' || toolName === 'grow_all') {
1436
  const state = await loadState(id);
1437
  const selected = new Set(toolName === 'grow_selected' ? state.selection : state.seeds.map((seed) => seed.seedId));
@@ -1443,7 +1445,7 @@ async function runAgentLinkTool(id: RuntimeIdentity, session: AgentLinkSession,
1443
  const parentOne = findSeed(state, left);
1444
  const parentTwo = findSeed(state, right);
1445
  if (!parentOne || !parentTwo) throw new Error('Parents not found');
1446
- result = predictCross(parentOne, parentTwo, typeof args.n === 'number' ? args.n : 300);
1447
  } else if (toolName === 'breed') {
1448
  const state = await loadState(id);
1449
  const [left, right] = selectedParentIds(state, args);
 
17
  newId,
18
  nowIso,
19
  predictCross,
20
+ rarityIndex,
21
+ rarityScore,
22
+ rarityTier,
23
  replaceSeeds,
24
+ vibrance,
25
  } from '../../shared';
26
+ import type { BusReceipt, GameState, RarityTier, SeedProfile } from '../../shared';
27
 
28
  type RuntimeIdentity = { postId: string; username: string };
29
  const BROKER_USERNAME = 'Maryjane_Broker';
 
75
 
76
  type SeedValuation = {
77
  score: number;
78
+ tier: RarityTier;
79
  commonCloneEquivalent: number;
80
  leaderboardEvidence: Array<{ metric: string; rank: number; score: number }>;
81
  traits: {
 
256
  { name: 'grow', label: 'Grow specimen', scope: 'garden', risk: 'mutating', description: 'Advance one specimen by one growth stage.', input: { seed_id: { type: 'string' } } },
257
  { name: 'grow_selected', label: 'Grow selected', scope: 'garden', risk: 'mutating', description: 'Advance each selected on-deck specimen by one stage.', input: {} },
258
  { name: 'grow_all', label: 'Grow all', scope: 'garden', risk: 'mutating', description: 'Advance every immature specimen by one stage.', input: {} },
259
+ { name: 'predict_cross', label: 'Predict cross', scope: 'breed', risk: 'inspect', description: 'Run the cross predictor for two parents or current on-deck plants. Pass summary_only=true to drop raw per-sample arrays and color clouds for a lighter payload.', input: { parent1_id: { type: 'string' }, parent2_id: { type: 'string' }, n: { type: 'integer' }, summary_only: { type: 'boolean' } } },
260
  { name: 'breed', label: 'Breed parents', scope: 'breed', risk: 'mutating', description: 'Breed two parents or the current on-deck plants.', input: { parent1_id: { type: 'string' }, parent2_id: { type: 'string' } } },
261
  { name: 'clone', label: 'Clone plant', scope: 'garden', risk: 'mutating', description: 'Clone one plant into the owner inventory.', input: { seed_id: { type: 'string' } } },
262
  { name: 'inspect_market', label: 'Inspect exchange', scope: 'market', risk: 'inspect', description: 'Read clone exchange listings, offers, sequence candidates, and order-book stats.', input: {} },
 
436
  return missing.length ? replaceSeeds(state, [...state.seeds, ...missing]) : state;
437
  }
438
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  function valuationFromRecord(record: LabRecord, leaderboard: LeaderboardEntry[]): SeedValuation {
440
  const vibranceScore = vibrance(record.budColor);
441
  const bloomScore = record.Bloom;
442
+ const score = rarityScore({
443
+ thc: record.THC,
444
+ cbd: record.CBD,
445
+ yield: record.Yield,
446
+ growTime: record.GrowTime,
447
+ stability: record.Stability,
448
+ generation: record.generation,
449
+ vibrance: vibranceScore,
450
+ bloom: bloomScore,
451
+ canAttempt: record.canAttempt,
452
+ });
453
  const evidence = leaderboard
454
  .filter((entry) => entry.seedId === record.germplasmDbId && entry.rank !== undefined && entry.rank <= 10)
455
  .map((entry) => ({ metric: entry.metric, rank: entry.rank ?? 0, score: entry.score }))
456
  .slice(0, 6);
457
  return {
458
  score,
459
+ tier: rarityTier(rarityIndex({
460
+ thc: record.THC,
461
+ cbd: record.CBD,
462
+ yield: record.Yield,
463
+ growTime: record.GrowTime,
464
+ stability: record.Stability,
465
+ generation: record.generation,
466
+ vibrance: vibranceScore,
467
+ bloom: bloomScore,
468
+ canAttempt: record.canAttempt,
469
+ })),
470
  commonCloneEquivalent: Math.max(1, Math.min(12, Math.round(score / 18))),
471
  leaderboardEvidence: evidence,
472
  traits: {
 
1432
  const seed = findSeed(state, seedId);
1433
  if (!seed) throw new Error('Seed not found');
1434
  const next = await saveState(id, replaceSeeds(state, state.seeds.map((item) => item.seedId === seedId ? { ...item, growthStage: advanceGrowthStage(item.growthStage) } : item)));
1435
+ const grown = findSeed(next, seedId);
1436
+ result = { seed: grown ? toInventorySeed(grown) : null };
1437
  } else if (toolName === 'grow_selected' || toolName === 'grow_all') {
1438
  const state = await loadState(id);
1439
  const selected = new Set(toolName === 'grow_selected' ? state.selection : state.seeds.map((seed) => seed.seedId));
 
1445
  const parentOne = findSeed(state, left);
1446
  const parentTwo = findSeed(state, right);
1447
  if (!parentOne || !parentTwo) throw new Error('Parents not found');
1448
+ result = predictCross(parentOne, parentTwo, typeof args.n === 'number' ? args.n : 300, args.summary_only === true);
1449
  } else if (toolName === 'breed') {
1450
  const state = await loadState(id);
1451
  const [left, right] = selectedParentIds(state, args);
src/server/routes/api.ts CHANGED
@@ -397,7 +397,7 @@ api.post('/lab/predict-cross', async (c) => {
397
  const parentOne = findSeed(state, request.parent1_id);
398
  const parentTwo = findSeed(state, request.parent2_id);
399
  if (!parentOne || !parentTwo) return jsonError(c, 'Parents not found', 404);
400
- return c.json(predictCross(parentOne, parentTwo, request.n ?? 200));
401
  });
402
 
403
  api.get('/lab/newick', async (c) => {
 
397
  const parentOne = findSeed(state, request.parent1_id);
398
  const parentTwo = findSeed(state, request.parent2_id);
399
  if (!parentOne || !parentTwo) return jsonError(c, 'Parents not found', 404);
400
+ return c.json(predictCross(parentOne, parentTwo, request.n ?? 200, request.summary_only === true));
401
  });
402
 
403
  api.get('/lab/newick', async (c) => {
src/shared/api.ts CHANGED
@@ -30,6 +30,7 @@ export type CrossRequest = {
30
  parent1_id: string;
31
  parent2_id: string;
32
  n?: number;
 
33
  };
34
 
35
  export type BreedRequest = {
 
30
  parent1_id: string;
31
  parent2_id: string;
32
  n?: number;
33
+ summary_only?: boolean;
34
  };
35
 
36
  export type BreedRequest = {
src/shared/breeder.ts CHANGED
@@ -258,7 +258,11 @@ export function breedInState(
258
  if (!parentOne || !parentTwo) {
259
  throw new Error('Parents not found');
260
  }
261
- const offspring = breedProfiles(parentOne, parentTwo, rng);
 
 
 
 
262
  const seeds = state.seeds.map((seed) => {
263
  if (seed.seedId === parentOne.seedId || seed.seedId === parentTwo.seedId) {
264
  return { ...seed, attemptsUsed: seed.attemptsUsed + 1 };
 
258
  if (!parentOne || !parentTwo) {
259
  throw new Error('Parents not found');
260
  }
261
+ let offspring = breedProfiles(parentOne, parentTwo, rng);
262
+ const takenNames = new Set(state.seeds.map((seed) => seed.strainName));
263
+ if (takenNames.has(offspring.strainName)) {
264
+ offspring = { ...offspring, strainName: `${offspring.strainName} ${offspring.seedId.slice(-4)}` };
265
+ }
266
  const seeds = state.seeds.map((seed) => {
267
  if (seed.seedId === parentOne.seedId || seed.seedId === parentTwo.seedId) {
268
  return { ...seed, attemptsUsed: seed.attemptsUsed + 1 };
src/shared/color.ts CHANGED
@@ -81,6 +81,11 @@ export function hueFromRgb(rgb: Rgb): number {
81
  return Math.round(rgbToHsv(rgb)[0] * 10) / 10;
82
  }
83
 
 
 
 
 
 
84
  export function colorFamily(hue: number): string {
85
  if (hue < 15 || hue >= 330) return 'red';
86
  if (hue < 40) return 'orange';
 
81
  return Math.round(rgbToHsv(rgb)[0] * 10) / 10;
82
  }
83
 
84
+ export function vibrance(rgb: Rgb): number {
85
+ const [red, green, blue] = rgb;
86
+ return Math.round(((Math.max(red, green, blue) - Math.min(red, green, blue)) / 255) * 1000) / 10;
87
+ }
88
+
89
  export function colorFamily(hue: number): string {
90
  if (hue < 15 || hue >= 330) return 'red';
91
  if (hue < 40) return 'orange';
src/shared/index.ts CHANGED
@@ -9,3 +9,4 @@ export * from './phenotype';
9
  export * from './rng';
10
  export * from './state';
11
  export * from './types';
 
 
9
  export * from './rng';
10
  export * from './state';
11
  export * from './types';
12
+ export * from './valuation';
src/shared/lab.ts CHANGED
@@ -125,7 +125,15 @@ export function labPayload(seeds: SeedProfile[]): LabPayload {
125
  };
126
  }
127
 
128
- function summarize(values: number[]): DistributionSummary {
 
 
 
 
 
 
 
 
129
  const sorted = [...values].sort((left, right) => left - right);
130
  const total = sorted.reduce((sum, value) => sum + value, 0);
131
  return {
@@ -133,7 +141,7 @@ function summarize(values: number[]): DistributionSummary {
133
  max: sorted[sorted.length - 1] ?? 0,
134
  mean: sorted.length ? Math.round((total / sorted.length) * 100) / 100 : 0,
135
  median: sorted[Math.floor(sorted.length / 2)] ?? 0,
136
- values,
137
  };
138
  }
139
 
@@ -149,11 +157,11 @@ function bloomHint(summary: { score: DistributionSummary; volatility: Distributi
149
  return `stable expression: safer traits, lower bloom odds`;
150
  }
151
 
152
- function bloomSummary(scores: number[], volatilities: number[], colorCounts: number[], patterns: Record<ColorPattern, number>): BloomPredictionSummary {
153
  const summary = {
154
- score: summarize(scores),
155
- volatility: summarize(volatilities),
156
- colorCount: summarize(colorCounts),
157
  maxColors: Math.max(0, ...colorCounts),
158
  patterns,
159
  hint: '',
@@ -161,7 +169,7 @@ function bloomSummary(scores: number[], volatilities: number[], colorCounts: num
161
  return { ...summary, hint: bloomHint(summary) };
162
  }
163
 
164
- export function predictCross(parentOne: SeedProfile, parentTwo: SeedProfile, sampleCount = 200): CrossPrediction {
165
  const n = Math.max(20, Math.min(1000, sampleCount));
166
  const thc: number[] = [];
167
  const cbd: number[] = [];
@@ -199,17 +207,18 @@ export function predictCross(parentOne: SeedProfile, parentTwo: SeedProfile, sam
199
  incrementPattern(leafPatterns, phenotype.leafPattern);
200
  }
201
 
 
202
  return {
203
  n,
204
  parents: [parentOne.strainName, parentTwo.strainName],
205
- THC: summarize(thc),
206
- CBD: summarize(cbd),
207
- Yield: summarize(yieldValues),
208
- GrowTime: summarize(growTime),
209
- budCloud,
210
- leafCloud,
211
- budBloom: bloomSummary(budBloomScores, budVolatility, budColorCounts, budPatterns),
212
- leafBloom: bloomSummary(leafBloomScores, leafVolatility, leafColorCounts, leafPatterns),
213
  };
214
  }
215
 
 
125
  };
126
  }
127
 
128
+ function downsample<T>(values: T[], cap: number): T[] {
129
+ if (values.length <= cap) return values;
130
+ const step = values.length / cap;
131
+ const out: T[] = [];
132
+ for (let index = 0; index < cap; index += 1) out.push(values[Math.floor(index * step)] as T);
133
+ return out;
134
+ }
135
+
136
+ function summarize(values: number[], keepValues = true, cap = 80): DistributionSummary {
137
  const sorted = [...values].sort((left, right) => left - right);
138
  const total = sorted.reduce((sum, value) => sum + value, 0);
139
  return {
 
141
  max: sorted[sorted.length - 1] ?? 0,
142
  mean: sorted.length ? Math.round((total / sorted.length) * 100) / 100 : 0,
143
  median: sorted[Math.floor(sorted.length / 2)] ?? 0,
144
+ values: keepValues ? downsample(values, cap) : [],
145
  };
146
  }
147
 
 
157
  return `stable expression: safer traits, lower bloom odds`;
158
  }
159
 
160
+ function bloomSummary(scores: number[], volatilities: number[], colorCounts: number[], patterns: Record<ColorPattern, number>, keepValues = true): BloomPredictionSummary {
161
  const summary = {
162
+ score: summarize(scores, keepValues),
163
+ volatility: summarize(volatilities, keepValues),
164
+ colorCount: summarize(colorCounts, keepValues),
165
  maxColors: Math.max(0, ...colorCounts),
166
  patterns,
167
  hint: '',
 
169
  return { ...summary, hint: bloomHint(summary) };
170
  }
171
 
172
+ export function predictCross(parentOne: SeedProfile, parentTwo: SeedProfile, sampleCount = 200, summaryOnly = false): CrossPrediction {
173
  const n = Math.max(20, Math.min(1000, sampleCount));
174
  const thc: number[] = [];
175
  const cbd: number[] = [];
 
207
  incrementPattern(leafPatterns, phenotype.leafPattern);
208
  }
209
 
210
+ const keepValues = !summaryOnly;
211
  return {
212
  n,
213
  parents: [parentOne.strainName, parentTwo.strainName],
214
+ THC: summarize(thc, keepValues),
215
+ CBD: summarize(cbd, keepValues),
216
+ Yield: summarize(yieldValues, keepValues),
217
+ GrowTime: summarize(growTime, keepValues),
218
+ budCloud: summaryOnly ? [] : downsample(budCloud, 80),
219
+ leafCloud: summaryOnly ? [] : downsample(leafCloud, 80),
220
+ budBloom: bloomSummary(budBloomScores, budVolatility, budColorCounts, budPatterns, keepValues),
221
+ leafBloom: bloomSummary(leafBloomScores, leafVolatility, leafColorCounts, leafPatterns, keepValues),
222
  };
223
  }
224
 
src/shared/valuation.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type RarityTier =
2
+ | 'common'
3
+ | 'uncommon'
4
+ | 'notable'
5
+ | 'rare'
6
+ | 'elite'
7
+ | 'exotic'
8
+ | 'legendary'
9
+ | 'mythic'
10
+ | 'primordial';
11
+
12
+ // Ascending order (weakest -> apex). Index positions double as a numeric rank.
13
+ export const RARITY_TIERS: RarityTier[] = [
14
+ 'common',
15
+ 'uncommon',
16
+ 'notable',
17
+ 'rare',
18
+ 'elite',
19
+ 'exotic',
20
+ 'legendary',
21
+ 'mythic',
22
+ 'primordial',
23
+ ];
24
+
25
+ export type RarityInput = {
26
+ thc: number;
27
+ cbd: number;
28
+ yield: number;
29
+ growTime: number;
30
+ stability: number;
31
+ generation: number;
32
+ vibrance: number;
33
+ bloom: number;
34
+ canAttempt: boolean;
35
+ };
36
+
37
+ function clamp01(value: number): number {
38
+ return Math.max(0, Math.min(1, value));
39
+ }
40
+
41
+ // Market valuation: unbounded, drives pricing (commonCloneEquivalent) and the
42
+ // leaderboard "Rarity" metric. Left intentionally as-is so the economy is untouched.
43
+ export function rarityScore(input: RarityInput): number {
44
+ return Math.round((
45
+ input.thc * 1.35 +
46
+ input.cbd * 0.55 +
47
+ input.yield * 0.18 +
48
+ Math.max(0, 90 - input.growTime) * 0.18 +
49
+ input.stability * 18 +
50
+ input.generation * 4 +
51
+ input.vibrance * 0.16 +
52
+ input.bloom * 0.14 +
53
+ (input.canAttempt ? 5 : 0)
54
+ ) * 10) / 10;
55
+ }
56
+
57
+ // Bounded 0-100 collectibility index that drives the visual rarity TIER only.
58
+ // Every term is normalized to 0..1 and generation saturates (gen/(gen+6)) so
59
+ // breeding deeper cannot inflate a plant into the top tier forever -- the top
60
+ // tiers stay rare permanently, unlike the raw score.
61
+ export function rarityIndex(input: RarityInput): number {
62
+ const potency = clamp01(input.thc / 40);
63
+ const cannabinoid = clamp01(input.cbd / 18);
64
+ const yieldN = clamp01((input.yield - 25) / 135);
65
+ const speed = clamp01((90 - input.growTime) / 50);
66
+ const stability = clamp01(input.stability);
67
+ const bloomN = clamp01(input.bloom / 100);
68
+ const vibranceN = clamp01(input.vibrance / 100);
69
+ const genN = input.generation / (input.generation + 6);
70
+ const blended =
71
+ potency * 0.2 +
72
+ bloomN * 0.18 +
73
+ genN * 0.15 +
74
+ vibranceN * 0.12 +
75
+ yieldN * 0.1 +
76
+ stability * 0.1 +
77
+ speed * 0.09 +
78
+ cannabinoid * 0.06;
79
+ return Math.round(clamp01(blended) * 1000) / 10;
80
+ }
81
+
82
+ // Thresholds calibrated against the simulated index distribution (the index tops
83
+ // out near 80 in practice) so every tier is reachable and legendary+ stay scarce:
84
+ // veterans land ~58% exotic / 12% legendary / 2% mythic / 0.1% primordial, while
85
+ // early players span common->rare with headroom to climb.
86
+ export function rarityTier(index: number): RarityTier {
87
+ if (index >= 77) return 'primordial';
88
+ if (index >= 74) return 'mythic';
89
+ if (index >= 70) return 'legendary';
90
+ if (index >= 65) return 'exotic';
91
+ if (index >= 60) return 'elite';
92
+ if (index >= 55) return 'rare';
93
+ if (index >= 49) return 'notable';
94
+ if (index >= 43) return 'uncommon';
95
+ return 'common';
96
+ }
97
+
98
+ export function rarityRank(tier: RarityTier): number {
99
+ return RARITY_TIERS.indexOf(tier);
100
+ }