Checkpoint Devvit v0.0.26
Browse filesRarity 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 +139 -10
- src/client/index.css +104 -1
- src/server/routes/agentLink.ts +32 -30
- src/server/routes/api.ts +1 -1
- src/shared/api.ts +1 -0
- src/shared/breeder.ts +5 -1
- src/shared/color.ts +5 -0
- src/shared/index.ts +1 -0
- src/shared/lab.ts +24 -15
- src/shared/valuation.ts +100 -0
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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={`${
|
| 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>{
|
| 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
|
| 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)} /
|
| 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)} /
|
| 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>
|
|
@@ -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; }
|
|
@@ -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:
|
| 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 =
|
| 452 |
-
record.THC
|
| 453 |
-
record.CBD
|
| 454 |
-
record.Yield
|
| 455 |
-
|
| 456 |
-
record.Stability
|
| 457 |
-
record.generation
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
)
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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);
|
|
@@ -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) => {
|
|
@@ -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 = {
|
|
@@ -258,7 +258,11 @@ export function breedInState(
|
|
| 258 |
if (!parentOne || !parentTwo) {
|
| 259 |
throw new Error('Parents not found');
|
| 260 |
}
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 };
|
|
@@ -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';
|
|
@@ -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';
|
|
@@ -125,7 +125,15 @@ export function labPayload(seeds: SeedProfile[]): LabPayload {
|
|
| 125 |
};
|
| 126 |
}
|
| 127 |
|
| 128 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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 |
+
}
|