Jedmund/image embeds 2 (#424)

## Component Refactors:
- Updated `CharacterHovercard` to improve over mastery and awakening
section logic.
- Refactored `CharacterModal` to streamline state management (rings,
awakening, perpetuity) and object preparation.
- Adjusted `CharacterUnit` for consistent over mastery handling.
- Simplified `AwakeningSelectWithInput` to use awakening slug values and
improve error handling.
- Updated `RingSelect` to refine ring value syncing and index logic.
- Modified `Party` and `PartyHead` to ensure consistent over mastery
processing and proper preview URL construction.
- Updated `WeaponModal` to align awakening value handling with the new
slug-based approach.

## Styling and Configuration:
- Improved grid layout and styling in the `WeaponRep` SCSS module.
- Updated `next.config.js` rewrite rules to support new preview and
character routes.
- Added a new API endpoint (`pages/api/preview/[shortcode].tsx`) for
fetching party preview images.

## Type Definitions:
- Refined types in `types/GridCharacter.d.ts` and `types/index.d.ts` to
reflect updated structures for rings, over mastery, and awakening.
This commit is contained in:
Justin Edmund 2025-02-09 22:54:15 -08:00 committed by GitHub
parent eff96e5a37
commit a02a6c70aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 293 additions and 232 deletions

5
.aidigestignore Normal file
View file

@ -0,0 +1,5 @@
public/images
public/labels
public/profiles
tsconfig.tsbuildinfo
*.log

1
.gitignore vendored
View file

@ -86,3 +86,4 @@ typings/
# DS_Store
.DS_Store
*.tsbuildinfo
codebase.md

View file

@ -65,7 +65,7 @@ const CharacterHovercard = (props: Props) => {
}
const overMasterySection = () => {
if (props.gridCharacter && props.gridCharacter.over_mastery) {
if (props.gridCharacter && props.gridCharacter.over_mastery.length > 0) {
return (
<section className={styles.mastery}>
<h5 className={tintElement}>
@ -73,14 +73,13 @@ const CharacterHovercard = (props: Props) => {
</h5>
<ul>
{[...Array(4)].map((e, i) => {
const ringIndex = i + 1
const ringStat: ExtendedMastery =
props.gridCharacter.over_mastery[ringIndex]
props.gridCharacter.over_mastery[i]
if (ringStat && ringStat.modifier && ringStat.modifier > 0) {
if (ringIndex === 1 || ringIndex === 2) {
if (i === 0 || i === 1) {
return masteryElement(overMastery.a, ringStat)
} else if (ringIndex === 3) {
} else if (i === 2) {
return masteryElement(overMastery.b, ringStat)
} else {
return masteryElement(overMastery.c, ringStat)
@ -96,8 +95,9 @@ const CharacterHovercard = (props: Props) => {
const aetherialMasterySection = () => {
if (
props.gridCharacter &&
props.gridCharacter.over_mastery &&
props.gridCharacter.aetherial_mastery &&
props.gridCharacter.aetherial_mastery.modifier > 0
props.gridCharacter.aetherial_mastery?.modifier > 0
) {
return (
<section className={styles.mastery}>
@ -136,9 +136,8 @@ const CharacterHovercard = (props: Props) => {
}
const awakeningSection = () => {
if (props.gridCharacter.awakening) {
const gridAwakening = props.gridCharacter.awakening
if (gridAwakening) {
return (
<section className={styles.awakening}>
<h5 className={tintElement}>

View file

@ -44,6 +44,13 @@ interface Props {
updateCharacter: (object: GridCharacterObject) => Promise<any>
}
const AWAKENING_MAP: { [key: string]: string } = {
'character-balanced': 'b1847c82-ece0-4d7a-8af1-c7868d90f34a',
'character-atk': '6e233877-8cda-4c8f-a091-3db6f68749e2',
'character-def': 'c95441de-f949-4a62-b02b-101aa2e0a638',
'character-multi': 'e36b0573-79c3-4dd2-9524-c95def4bbb1a',
}
const CharacterModal = ({
gridCharacter,
children,
@ -64,12 +71,7 @@ const CharacterModal = ({
// State: Data
const [perpetuity, setPerpetuity] = useState(false)
const [rings, setRings] = useState<CharacterOverMastery>({
1: { ...emptyExtendedMastery, modifier: 1 },
2: { ...emptyExtendedMastery, modifier: 2 },
3: emptyExtendedMastery,
4: emptyExtendedMastery,
})
const [rings, setRings] = useState<CharacterOverMastery>([])
const [earring, setEarring] = useState<ExtendedMastery>(emptyExtendedMastery)
const [awakening, setAwakening] = useState<Awakening>()
const [awakeningLevel, setAwakeningLevel] = useState(1)
@ -94,46 +96,36 @@ const CharacterModal = ({
})
}
if (gridCharacter.awakening) {
setAwakening(gridCharacter.awakening.type)
setAwakeningLevel(gridCharacter.awakening.level)
}
setPerpetuity(gridCharacter.perpetuity)
}, [gridCharacter])
// Prepare the GridWeaponObject to send to the server
function prepareObject() {
let object: GridCharacterObject = {
function prepareObject(): GridCharacterObject {
return {
character: {
ring1: {
modifier: rings[1].modifier,
strength: rings[1].strength,
},
ring2: {
modifier: rings[2].modifier,
strength: rings[2].strength,
},
ring3: {
modifier: rings[3].modifier,
strength: rings[3].strength,
},
ring4: {
modifier: rings[4].modifier,
strength: rings[4].strength,
},
rings: rings, // your local rings array
earring: {
modifier: earring.modifier,
strength: earring.strength,
strength:
earring.modifier && earring.modifier > 0 ? earring.strength : 0,
},
// Only include awakening if one is set.
...(awakening
? {
awakening: {
id: awakening.id,
level: awakeningLevel,
},
}
: {}),
transcendence_step: transcendenceStep,
perpetuity: perpetuity,
},
}
if (awakening) {
object.character.awakening_id = awakening.id
object.character.awakening_level = awakeningLevel
}
return object
}
// Methods: Modification checking
@ -152,12 +144,12 @@ const CharacterModal = ({
function ringsChanged() {
// Create an empty ExtendedMastery object
const emptyRingset: CharacterOverMastery = {
1: { ...emptyExtendedMastery, modifier: 1 },
2: { ...emptyExtendedMastery, modifier: 2 },
3: emptyExtendedMastery,
4: emptyExtendedMastery,
}
const emptyRingset: CharacterOverMastery = [
{ ...emptyExtendedMastery, modifier: 1 },
{ ...emptyExtendedMastery, modifier: 2 },
emptyExtendedMastery,
emptyExtendedMastery,
]
// Check if the current ringset is empty on the current GridCharacter and our local state
const isEmptyRingset =
@ -195,8 +187,8 @@ const CharacterModal = ({
function awakeningChanged() {
// Check if the awakening in local state is different from the one on the current GridCharacter
const awakeningChanged =
!isEqual(gridCharacter.awakening.type, awakening) ||
gridCharacter.awakening.level !== awakeningLevel
!isEqual(gridCharacter.awakening?.type, awakening) ||
gridCharacter.awakening?.level !== awakeningLevel
// Return true if the awakening has been modified and is not empty
return awakeningChanged
@ -227,8 +219,26 @@ const CharacterModal = ({
})
}
function receiveAwakeningValues(id: string, level: number) {
setAwakening(gridCharacter.object.awakenings.find((a) => a.id === id))
function receiveAwakeningValues(slug: string, level: number) {
const mappedId = AWAKENING_MAP[slug] || null
const existingAwakening = gridCharacter.object.awakenings.find(
(a) => a.slug === slug
)
if (existingAwakening && mappedId) {
setAwakening({
...existingAwakening,
id: mappedId,
})
} else {
setAwakening({
id: mappedId || '',
slug,
name: { en: '', jp: '' },
order: 0,
})
}
setAwakeningLevel(level)
}
@ -307,13 +317,13 @@ const CharacterModal = ({
object="earring"
dataSet={elementalizeAetherialMastery(gridCharacter)}
selectValue={
gridCharacter.aetherial_mastery
? gridCharacter.aetherial_mastery.modifier
gridCharacter.over_mastery && gridCharacter.aetherial_mastery
? gridCharacter.aetherial_mastery?.modifier
: 0
}
inputValue={
gridCharacter.aetherial_mastery
? gridCharacter.aetherial_mastery.strength
gridCharacter.over_mastery && gridCharacter.aetherial_mastery
? gridCharacter.aetherial_mastery?.strength
: 0
}
sendValidity={receiveValidity}

View file

@ -148,12 +148,12 @@ const CharacterUnit = ({
let character = cloneDeep(gridCharacter)
if (character.over_mastery) {
const overMastery: CharacterOverMastery = {
1: gridCharacter.over_mastery[0],
2: gridCharacter.over_mastery[1],
3: gridCharacter.over_mastery[2],
4: gridCharacter.over_mastery[3],
}
const overMastery: CharacterOverMastery = [
gridCharacter.over_mastery[0],
gridCharacter.over_mastery[1],
gridCharacter.over_mastery[2],
gridCharacter.over_mastery[3],
]
character.over_mastery = overMastery
}

View file

@ -1,4 +1,3 @@
// Core dependencies
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
@ -73,7 +72,8 @@ const AwakeningSelectWithInput = ({
setCurrentAwakening(awakening)
setCurrentLevel(level ? level : 1)
if (awakening) sendValidity(true)
// If there is an awakening (even if it's the default) we consider the field valid.
if (awakening || defaultAwakening) sendValidity(true)
}, [])
// Methods: UI state management
@ -90,35 +90,32 @@ const AwakeningSelectWithInput = ({
// Methods: Rendering
function generateOptions() {
const sortedDataSet = [...dataSet].sort((a, b) => {
return a.order - b.order
})
let options: React.ReactNode[] = sortedDataSet.map((awakening, i) => {
return generateItem(awakening)
})
const sortedDataSet = [...dataSet].sort((a, b) => a.order - b.order)
let options: React.ReactNode[] = sortedDataSet.map((awakening) =>
generateItem(awakening)
)
if (!dataSet.includes(defaultAwakening))
options.unshift(generateItem(defaultAwakening))
return options
}
function generateItem(awakening: Awakening) {
return (
<SelectItem key={awakening.slug} value={awakening.id}>
<SelectItem key={awakening.slug} value={awakening.slug}>
{awakening.name[locale]}
</SelectItem>
)
}
// Methods: User input detection
function handleSelectChange(id: string) {
function handleSelectChange(value: string) {
// Here, value is the awakening slug.
const input = inputRef.current
if (input && !handleInputError(parseFloat(input.value))) return
setCurrentAwakening(dataSet.find((awakening) => awakening.id === id))
sendValues(id, currentLevel)
const selectedAwakening = dataSet.find((a) => a.slug === value)
setCurrentAwakening(selectedAwakening)
sendValues(value, currentLevel)
}
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
@ -127,7 +124,10 @@ const AwakeningSelectWithInput = ({
const newLevel = parseInt(event.target.value)
setCurrentLevel(newLevel)
sendValues(currentAwakening ? currentAwakening.id : '0', newLevel)
sendValues(
currentAwakening ? currentAwakening.slug : defaultAwakening.slug,
newLevel
)
}
// Methods: Handle error
@ -135,16 +135,12 @@ const AwakeningSelectWithInput = ({
let error = ''
if (currentAwakening) {
if (value && value % 1 != 0) {
if (value && value % 1 !== 0) {
error = t(`awakening.errors.value_not_whole`)
} else if (value < 1) {
error = t(`awakening.errors.value_too_low`, {
minValue: 1,
})
error = t(`awakening.errors.value_too_low`, { minValue: 1 })
} else if (value > maxLevel) {
error = t(`awakening.errors.value_too_high`, {
maxValue: maxLevel,
})
error = t(`awakening.errors.value_too_high`, { maxValue: maxLevel })
} else if (!value || value <= 0) {
error = t(`awakening.errors.value_empty`)
} else {
@ -165,13 +161,9 @@ const AwakeningSelectWithInput = ({
const rangeString = () => {
let placeholder = ''
if (awakening) {
const minValue = 1
const maxValue = maxLevel
placeholder = `${minValue}~${maxValue}`
if (currentAwakening) {
placeholder = `1~${maxLevel}`
}
return placeholder
}
@ -180,7 +172,8 @@ const AwakeningSelectWithInput = ({
<div className={styles.set}>
<Select
key="awakening-type"
value={`${awakening ? awakening.id : defaultAwakening.id}`}
// Use the slug as the value
value={`${awakening ? awakening.slug : defaultAwakening.slug}`}
open={open}
disabled={selectDisabled}
onValueChange={handleSelectChange}
@ -200,7 +193,8 @@ const AwakeningSelectWithInput = ({
className={inputClasses}
fieldsetClassName={classNames({
hidden:
currentAwakening === undefined || currentAwakening.id === '0',
currentAwakening === undefined ||
currentAwakening.slug === defaultAwakening.slug,
})}
wrapperClassName="fullHeight"
bound={true}

View file

@ -25,21 +25,21 @@ interface Props {
const RingSelect = ({ gridCharacter, sendValues }: Props) => {
// Ring value states
const [rings, setRings] = useState<CharacterOverMastery>({
1: { ...emptyRing, modifier: 1 },
2: { ...emptyRing, modifier: 2 },
3: emptyRing,
4: emptyRing,
})
const [rings, setRings] = useState<CharacterOverMastery>([
{ ...emptyRing, modifier: 1 },
{ ...emptyRing, modifier: 2 },
emptyRing,
emptyRing,
])
useEffect(() => {
if (gridCharacter.over_mastery) {
setRings({
1: gridCharacter.over_mastery[1],
2: gridCharacter.over_mastery[2],
3: gridCharacter.over_mastery[3],
4: gridCharacter.over_mastery[4],
})
setRings([
gridCharacter.over_mastery[0],
gridCharacter.over_mastery[1],
gridCharacter.over_mastery[2],
gridCharacter.over_mastery[3],
])
}
}, [gridCharacter])
@ -64,13 +64,13 @@ const RingSelect = ({ gridCharacter, sendValues }: Props) => {
}
switch (index) {
case 1:
case 0:
return overMastery.a ? [overMastery.a[0]] : []
case 2:
case 1:
return overMastery.a ? [overMastery.a[1]] : []
case 3:
case 2:
return overMastery.b ? [noValue, ...overMastery.b] : []
case 4:
case 3:
return overMastery.c ? [noValue, ...overMastery.c] : []
default:
return []
@ -78,72 +78,74 @@ const RingSelect = ({ gridCharacter, sendValues }: Props) => {
}
function receiveRingValues(index: number, left: number, right: number) {
// console.log(`Receiving values from ${index}: ${left} ${right}`)
if (index == 1 || index == 2) {
setSyncedRingValues(index, right)
} else if (index == 3 && left == 0) {
setRings({
...rings,
3: {
modifier: 0,
strength: 0,
},
4: {
modifier: 0,
strength: 0,
},
if (index === 0 || index === 1) {
// For rings 1 and 2 (indices 0 and 1), update using the synced function.
setSyncedRingValues(index as 0 | 1, right)
} else if (index === 2 && left === 0) {
// If ring 3 (index 2) is being unset (left is 0), then also unset ring 4.
setRings((prev) => {
const newRings = [...prev]
newRings[2] = { modifier: 0, strength: 0 }
newRings[3] = { modifier: 0, strength: 0 }
return newRings
})
} else {
setRings({
...rings,
[index]: {
modifier: left,
strength: right,
},
// For any other case (including ring 4 being unset), update only that ring.
setRings((prev) => {
const newRings = [...prev]
newRings[index] = { modifier: left, strength: right }
return newRings
})
}
}
function setSyncedRingValues(index: 1 | 2, value: number) {
// console.log(`Setting synced value for ${index} with value ${value}`)
const atkValues = (dataSet(1)[0] as ItemSkill).values ?? []
const hpValues = (dataSet(2)[0] as ItemSkill).values ?? []
function setSyncedRingValues(changedIndex: 0 | 1, newStrength: number) {
// Assume dataSet(0) holds the attack-related data and dataSet(1) holds the HP-related data.
// (Adjust these calls if your datasets are in different positions.)
const attackItem = dataSet(0)[0] as ItemSkill
const hpItem = dataSet(1)[0] as ItemSkill
const found =
index === 1 ? atkValues.indexOf(value) : hpValues.indexOf(value)
const atkValue = atkValues[found] ?? 0
const hpValue = hpValues[found] ?? 0
const attackValues: number[] = attackItem.values ?? []
const hpValues: number[] = hpItem.values ?? []
setRings({
...rings,
1: {
modifier: 1,
strength: atkValue,
},
2: {
modifier: 2,
strength: hpValue,
},
// Determine the index based on which ring changed:
const selectedIndex =
changedIndex === 0
? attackValues.indexOf(newStrength)
: hpValues.indexOf(newStrength)
// If the new strength value isnt found, do nothing.
if (selectedIndex === -1) {
return
}
// Get the corresponding values for both rings.
const newAttackValue = attackValues[selectedIndex] ?? 0
const newHpValue = hpValues[selectedIndex] ?? 0
// Update both ring values simultaneously.
setRings((prev) => {
const newRings = [...prev]
newRings[0] = { modifier: 1, strength: newAttackValue }
newRings[1] = { modifier: 2, strength: newHpValue }
return newRings
})
}
return (
<div className={styles.rings}>
{[...Array(4)].map((e, i) => {
const index = i + 1
const ringStat = rings[index]
{rings.map((ringStat, i) => {
return (
<ExtendedMasterySelect
name={`ring-${index}`}
name={`ring-${i}`}
object="ring"
key={`ring-${index}`}
dataSet={dataSet(index)}
leftSelectDisabled={index === 1 || index === 2}
leftSelectValue={ringStat.modifier ? ringStat.modifier : 0}
rightSelectValue={ringStat.strength ? ringStat.strength : 0}
key={`ring-${i}`}
dataSet={dataSet(i)}
leftSelectDisabled={i === 0 || i === 1}
leftSelectValue={ringStat?.modifier ?? 0}
rightSelectValue={ringStat?.strength ?? 0}
sendValues={(left: number, right: number) => {
receiveRingValues(index, left, right)
receiveRingValues(i, left, right)
}}
/>
)

View file

@ -324,13 +324,13 @@ const Party = (props: Props) => {
list.forEach((object: GridCharacter) => {
let character = clonedeep(object)
if (character.over_mastery) {
const overMastery: CharacterOverMastery = {
1: object.over_mastery[0],
2: object.over_mastery[1],
3: object.over_mastery[2],
4: object.over_mastery[3],
}
if (character.over_mastery && character.over_mastery) {
const overMastery: CharacterOverMastery = [
object.over_mastery[0],
object.over_mastery[1],
object.over_mastery[2],
object.over_mastery[3],
]
character.over_mastery = overMastery
}

View file

@ -21,7 +21,7 @@ const PartyHead = ({ party, meta }: Props) => {
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const previewUrl = `${
process.env.NEXT_PUBLIC_SITE_URL || 'https://granblue.team'
}/preview/${party.shortcode}`
}/p/${party.shortcode}/preview`
return (
<Head>

View file

@ -1,51 +1,67 @@
// Overall container never taller than $rep-height:
.rep {
aspect-ratio: 2/0.955;
border-radius: $card-corner;
display: grid;
grid-template-columns: 1fr 3.39fr; /* left column takes up 1 fraction, right column takes up 3 fractions */
grid-gap: $unit-half; /* add a gap of 8px between grid items */
height: $rep-height;
transition: $duration-opacity-fade opacity ease-in;
opacity: 0.5;
@include breakpoint(small-tablet) {
display: none;
display: grid;
// First column: mainhand width = $rep-height * (200/420)
// Second column: weapons grid its width will be auto (we calculate it below)
grid-template-columns:
calc(#{$rep-height} * (200 / 420))
auto;
gap: $unit-half;
box-sizing: border-box;
}
.mainhand,
/* --- Mainhand image --- */
.mainhand {
background: var(--card-bg);
border-radius: 4px;
height: 100%;
width: 100%; // takes the grid columns computed width
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: contain; // or "cover" if you prefer cropping
}
}
/* --- Weapons grid --- */
.weapons {
/* Reset default UL spacing */
margin: 0;
padding: 0;
list-style: none;
height: 100%;
display: grid;
// We know there will be 3 columns and 3 rows.
// Each row's height is one-third of $rep-height:
// Subtract the 2 vertical gaps from the total height before dividing:
grid-template-rows: repeat(
3,
calc((#{$rep-height} - (2 * #{$unit-half})) / 3)
);
// Each column's width is calculated as: (cell height * (280/160))
grid-template-columns: repeat(
3,
calc((#{$rep-height} - (2 * #{$unit-half})) / 3 * (280 / 160))
);
gap: $unit-half;
}
/* Each grid cell (a .weapon) */
.weapon {
background: var(--card-bg);
border-radius: 4px;
overflow: hidden;
// Center the image (or placeholder) within the cell:
display: flex;
align-items: center;
justify-content: center;
img[src*='jpg'] {
border-radius: 4px;
img {
width: 100%;
}
}
.mainhand {
aspect-ratio: 73/153;
display: grid;
grid-column: 1 / 2; /* spans one column */
}
.weapons {
display: grid; /* make the right-images container a grid */
grid-template-columns: repeat(
3,
1fr
); /* create 3 columns, each taking up 1 fraction */
grid-template-rows: repeat(
3,
1fr
); /* create 3 rows, each taking up 1 fraction */
gap: $unit-half;
// column-gap: $unit;
// row-gap: $unit-2x;
}
.weapon {
aspect-ratio: 280 / 160;
display: grid;
height: 100%;
}
}

View file

@ -136,13 +136,12 @@ const WeaponModal = ({
}
// Receive values from AwakeningSelectWithInput
function receiveAwakeningValues(id: string, level: number) {
setAwakening(gridWeapon.object.awakenings.find((a) => a.id === id))
console.log(level)
function receiveAwakeningValues(slug: string, level: number) {
// Look up the awakening by its slug, since the select sends a slug.
setAwakening(gridWeapon.object.awakenings.find((a) => a.slug === slug))
setAwakeningLevel(level)
setFormValid(true)
}
// Receive values from WeaponKeySelect
function receiveWeaponKey(value: WeaponKey, slot: number) {
if (slot === 0) setWeaponKey1(value)

View file

@ -0,0 +1,33 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import axios from 'axios'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { shortcode } = req.query
if (!shortcode || Array.isArray(shortcode)) {
return res.status(400).json({ error: 'Invalid shortcode' })
}
try {
const response = await axios({
method: 'GET',
url: `${process.env.NEXT_PUBLIC_SIERO_API_URL}/parties/${shortcode}/preview`,
responseType: 'arraybuffer',
headers: {
Accept: 'image/png',
},
})
// Set correct content type and caching headers
res.setHeader('Content-Type', 'image/png')
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
return res.send(response.data)
} catch (error) {
console.error('Error fetching preview:', error)
return res.status(500).json({ error: 'Failed to fetch preview' })
}
}

View file

@ -4,11 +4,11 @@ interface GridCharacter {
object: Character
uncap_level: number
transcendence_step: number
over_mastery: CharacterOverMastery
aetherial_mastery: ExtendedMastery
perpetuity: boolean
over_mastery: ExtendedMastery[]
aetherial_mastery?: ExtendedMastery
awakening: {
type: Awakening
level: number
}
perpetuity: boolean
}

28
types/index.d.ts vendored
View file

@ -48,23 +48,25 @@ export type ExtendedMastery = {
strength?: number
}
export type CharacterOverMastery = {
[key: number]: ExtendedMastery
1: ExtendedMastery
2: ExtendedMastery
3: ExtendedMastery
4: ExtendedMastery
export type CharacterOverMastery = ExtendedMastery[]
export interface MasteryBonuses {
awakening?: {
type: Awakening
level: number
}
over_mastery?: CharacterOverMastery
aetherial_mastery?: ExtendedMastery
}
interface GridCharacterObject {
export interface GridCharacterObject {
character: {
ring1: ExtendedMastery
ring2: ExtendedMastery
ring3: ExtendedMastery
ring4: ExtendedMastery
rings: ExtendedMastery[]
earring: ExtendedMastery
awakening_id?: string
awakening_level?: number
awakening?: {
id: string
level: number
}
transcendence_step: number
perpetuity: boolean
}