style: format code for better readability and consistency
All checks were successful
CI / Get Changed Files (pull_request) Successful in 10s
CI / Docker backend validation (pull_request) Successful in 17s
CI / Docker frontend validation (pull_request) Successful in 48s
CI / Checkstyle Main (pull_request) Has been skipped
CI / oxlint (pull_request) Successful in 27s
CI / eslint (pull_request) Successful in 31s
CI / prettier (pull_request) Successful in 36s
CI / test-build (pull_request) Successful in 1m1s

This commit is contained in:
Jan-Marlon Leibl 2025-05-07 14:57:24 +02:00
commit c7f26c4df3
Signed by: jleibl
GPG key ID: 300B2F906DC6F1D5
9 changed files with 175 additions and 135 deletions

View file

@ -28,7 +28,8 @@ export const routes: Routes = [
}, },
{ {
path: 'game/lootboxes', path: 'game/lootboxes',
loadComponent: () => import('./feature/lootboxes/lootbox-selection/lootbox-selection.component'), loadComponent: () =>
import('./feature/lootboxes/lootbox-selection/lootbox-selection.component'),
canActivate: [authGuard], canActivate: [authGuard],
}, },
{ {

View file

@ -308,7 +308,9 @@ body {
.animation-fade { .animation-fade {
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(10px);
transition: opacity 0.5s ease-out, transform 0.5s ease-out; transition:
opacity 0.5s ease-out,
transform 0.5s ease-out;
transition-delay: 0.5s; transition-delay: 0.5s;
} }

View file

@ -2,53 +2,60 @@
<div class="container mx-auto px-4 py-6 space-y-8"> <div class="container mx-auto px-4 py-6 space-y-8">
<h1 class="text-3xl font-bold text-white mb-6">Lootbox Öffnen</h1> <h1 class="text-3xl font-bold text-white mb-6">Lootbox Öffnen</h1>
<div *ngIf="isLoading" class="flex justify-center"> <div *ngIf="isLoading" class="flex justify-center">
<div class="loader"></div> <div class="loader"></div>
</div> </div>
<div *ngIf="error" class="bg-red-500 text-white p-4 rounded mb-6"> <div *ngIf="error" class="bg-red-500 text-white p-4 rounded mb-6">
{{ error }} {{ error }}
</div> </div>
<div *ngIf="!isLoading && lootbox" class="grid grid-cols-1 lg:grid-cols-4 gap-6"> <div *ngIf="!isLoading && lootbox" class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div class="lg:col-span-3"> <div class="lg:col-span-3">
<div class="card bg-deep-blue-light rounded-lg overflow-hidden shadow-lg"> <div class="card bg-deep-blue-light rounded-lg overflow-hidden shadow-lg">
<div class="relative" *ngIf="!isOpen"> <div class="relative" *ngIf="!isOpen">
<img [src]="getBoxImage(lootbox.id)" [alt]="lootbox.name" class="w-full h-48 object-cover"> <img
[src]="getBoxImage(lootbox.id)"
[alt]="lootbox.name"
class="w-full h-48 object-cover"
/>
<div class="absolute top-2 right-2 bg-deep-blue px-2 py-1 rounded-full text-white"> <div class="absolute top-2 right-2 bg-deep-blue px-2 py-1 rounded-full text-white">
{{ lootbox.price | currency:'EUR' }} {{ lootbox.price | currency: 'EUR' }}
</div> </div>
</div> </div>
<div class="p-6"> <div class="p-6">
<h2 class="text-xl font-bold text-white mb-4">{{ lootbox.name }}</h2> <h2 class="text-xl font-bold text-white mb-4">{{ lootbox.name }}</h2>
<div *ngIf="!isOpening && !isOpen" class="mb-6"> <div *ngIf="!isOpening && !isOpen" class="mb-6">
<button <button
(click)="openLootbox()" (click)="openLootbox()"
class="button-primary w-full py-3 text-lg font-semibold rounded"> class="button-primary w-full py-3 text-lg font-semibold rounded"
>
Öffnen Öffnen
</button> </button>
</div> </div>
<div *ngIf="isOpening" class="flex flex-col items-center py-8"> <div *ngIf="isOpening" class="flex flex-col items-center py-8">
<div class="loader mb-4"></div> <div class="loader mb-4"></div>
<div class="text-white text-lg">Öffne Lootbox...</div> <div class="text-white text-lg">Öffne Lootbox...</div>
</div> </div>
<div *ngIf="isOpen && !isOpening" class="space-y-6"> <div *ngIf="isOpen && !isOpening" class="space-y-6">
<div class="text-center mb-8 animation-fade" <div class="text-center mb-8 animation-fade" [class.visible]="animationCompleted">
[class.visible]="animationCompleted">
<h2 class="text-xl font-bold text-white mb-2">Dein Gewinn:</h2> <h2 class="text-xl font-bold text-white mb-2">Dein Gewinn:</h2>
<div class="text-3xl font-bold" [ngClass]="getRewardClass()"> <div class="text-3xl font-bold" [ngClass]="getRewardClass()">
{{ wonReward?.value | currency: 'EUR' }} {{ wonReward?.value | currency: 'EUR' }}
</div> </div>
</div> </div>
<div class="relative w-full overflow-hidden bg-deep-blue rounded-lg p-4 h-32 mb-6"> <div class="relative w-full overflow-hidden bg-deep-blue rounded-lg p-4 h-32 mb-6">
<div class="absolute left-1/2 top-0 bottom-0 w-0.5 bg-white z-10 transform -translate-x-0" style="margin-left: 0px;"></div> <div
class="absolute left-1/2 top-0 bottom-0 w-0.5 bg-white z-10 transform -translate-x-0"
style="margin-left: 0px"
></div>
<div class="relative h-full flex items-center overflow-hidden"> <div class="relative h-full flex items-center overflow-hidden">
<div class="flex case-items"> <div class="flex case-items">
<div <div
@ -57,9 +64,11 @@
[class.case-item-won]="isWonReward(reward) && animationCompleted" [class.case-item-won]="isWonReward(reward) && animationCompleted"
[id]="isWonReward(reward) ? 'winning-item' : ''" [id]="isWonReward(reward) ? 'winning-item' : ''"
> >
<div class="case-item-inner" <div
[class.winning-prize]="isWonReward(reward)" class="case-item-inner"
[class.highlight]="isWonReward(reward) && animationCompleted"> [class.winning-prize]="isWonReward(reward)"
[class.highlight]="isWonReward(reward) && animationCompleted"
>
<div class="amount" [ngClass]="getRewardRarityClass(reward)"> <div class="amount" [ngClass]="getRewardRarityClass(reward)">
{{ reward.value | currency: 'EUR' }} {{ reward.value | currency: 'EUR' }}
</div> </div>
@ -71,13 +80,12 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex justify-center gap-4 mt-8 animation-fade" <div
[class.visible]="animationCompleted"> class="flex justify-center gap-4 mt-8 animation-fade"
<button [class.visible]="animationCompleted"
(click)="openAgain()" >
class="button-primary px-6 py-2 font-semibold rounded" <button (click)="openAgain()" class="button-primary px-6 py-2 font-semibold rounded">
>
Nochmal Öffnen Nochmal Öffnen
</button> </button>
<button <button
@ -89,7 +97,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="px-6 pb-6"> <div class="px-6 pb-6">
<div class="text-sm text-text-secondary"> <div class="text-sm text-text-secondary">
<p>Fairness garantiert - Alle Ergebnisse werden transparent berechnet.</p> <p>Fairness garantiert - Alle Ergebnisse werden transparent berechnet.</p>
@ -97,18 +105,23 @@
</div> </div>
</div> </div>
</div> </div>
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<div class="card bg-deep-blue-light rounded-lg overflow-hidden shadow-lg p-6"> <div class="card bg-deep-blue-light rounded-lg overflow-hidden shadow-lg p-6">
<h3 class="text-lg font-semibold text-white mb-4">Mögliche Gewinne:</h3> <h3 class="text-lg font-semibold text-white mb-4">Mögliche Gewinne:</h3>
<ul class="space-y-3"> <ul class="space-y-3">
<li *ngFor="let reward of lootbox.rewards" class="flex justify-between"> <li *ngFor="let reward of lootbox.rewards" class="flex justify-between">
<span [ngClass]="getRewardRarityClass(reward)">{{ reward.value | currency:'EUR' }}</span> <span [ngClass]="getRewardRarityClass(reward)">{{
reward.value | currency: 'EUR'
}}</span>
<span class="text-white">{{ reward.probability * 100 | number: '1.0-0' }}%</span> <span class="text-white">{{ reward.probability * 100 | number: '1.0-0' }}%</span>
</li> </li>
</ul> </ul>
<div *ngIf="isOpen && wonReward && animationCompleted" class="mt-6 pt-6 border-t border-deep-blue-contrast"> <div
*ngIf="isOpen && wonReward && animationCompleted"
class="mt-6 pt-6 border-t border-deep-blue-contrast"
>
<h3 class="text-lg font-semibold text-white mb-4">Gewinn-Details:</h3> <h3 class="text-lg font-semibold text-white mb-4">Gewinn-Details:</h3>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
@ -121,8 +134,12 @@
</div> </div>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-text-secondary">Profit:</span> <span class="text-text-secondary">Profit:</span>
<span [ngClass]="wonReward.value > (lootbox.price || 0) ? 'text-emerald' : 'text-accent-red'"> <span
{{ (wonReward.value - (lootbox.price || 0)) | currency: 'EUR' }} [ngClass]="
wonReward.value > (lootbox.price || 0) ? 'text-emerald' : 'text-accent-red'
"
>
{{ wonReward.value - (lootbox.price || 0) | currency: 'EUR' }}
</span> </span>
</div> </div>
</div> </div>
@ -130,4 +147,4 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -38,7 +38,7 @@ export default class LootboxOpeningComponent {
this.isLoading = false; this.isLoading = false;
return; return;
} }
const lootboxId = parseInt(idParam, 10); const lootboxId = parseInt(idParam, 10);
this.lootboxService.getAllLootBoxes().subscribe({ this.lootboxService.getAllLootBoxes().subscribe({
next: (lootboxes) => { next: (lootboxes) => {
@ -56,9 +56,9 @@ export default class LootboxOpeningComponent {
openLootbox(): void { openLootbox(): void {
if (!this.lootbox || this.isOpening) return; if (!this.lootbox || this.isOpening) return;
this.resetState(true); this.resetState(true);
setTimeout(() => { setTimeout(() => {
this.lootboxService.purchaseLootBox(this.lootbox!.id).subscribe({ this.lootboxService.purchaseLootBox(this.lootbox!.id).subscribe({
next: this.handleRewardSuccess.bind(this), next: this.handleRewardSuccess.bind(this),
@ -77,7 +77,7 @@ export default class LootboxOpeningComponent {
private handleRewardError(): void { private handleRewardError(): void {
if (!this.lootbox) return; if (!this.lootbox) return;
const rewards = this.lootbox.rewards; const rewards = this.lootbox.rewards;
const fallback = rewards[Math.floor(Math.random() * rewards.length)]; const fallback = rewards[Math.floor(Math.random() * rewards.length)];
this.handleRewardSuccess(fallback); this.handleRewardSuccess(fallback);
@ -99,17 +99,17 @@ export default class LootboxOpeningComponent {
const winningPosition = Math.floor(prizeCount / 2); const winningPosition = Math.floor(prizeCount / 2);
const possibleRewards = this.lootbox.rewards; const possibleRewards = this.lootbox.rewards;
const items: Reward[] = []; const items: Reward[] = [];
for (let i = 0; i < prizeCount; i++) { for (let i = 0; i < prizeCount; i++) {
if (i === winningPosition) { if (i === winningPosition) {
items.push({...wonReward}); items.push({ ...wonReward });
} else { } else {
items.push(this.getWeightedRandomReward(possibleRewards)); items.push(this.getWeightedRandomReward(possibleRewards));
} }
} }
this.prizeList = items; this.prizeList = items;
setTimeout(() => { setTimeout(() => {
this.animationCompleted = true; this.animationCompleted = true;
this.cdr.detectChanges(); this.cdr.detectChanges();
@ -120,14 +120,14 @@ export default class LootboxOpeningComponent {
const totalProbability = rewards.reduce((sum, reward) => sum + reward.probability, 0); const totalProbability = rewards.reduce((sum, reward) => sum + reward.probability, 0);
const randomValue = Math.random() * totalProbability; const randomValue = Math.random() * totalProbability;
let cumulativeProbability = 0; let cumulativeProbability = 0;
for (const reward of rewards) { for (const reward of rewards) {
cumulativeProbability += reward.probability; cumulativeProbability += reward.probability;
if (randomValue <= cumulativeProbability) { if (randomValue <= cumulativeProbability) {
return { ...reward }; return { ...reward };
} }
} }
return { ...rewards[0] }; return { ...rewards[0] };
} }
@ -146,27 +146,26 @@ export default class LootboxOpeningComponent {
isWonReward(reward: Reward): boolean { isWonReward(reward: Reward): boolean {
if (!this.wonReward || !this.prizeList.length) return false; if (!this.wonReward || !this.prizeList.length) return false;
const middleIndex = Math.floor(this.prizeList.length / 2); const middleIndex = Math.floor(this.prizeList.length / 2);
return this.prizeList.indexOf(reward) === middleIndex; return this.prizeList.indexOf(reward) === middleIndex;
} }
getRewardRarityClass(reward: Reward): string { getRewardRarityClass(reward: Reward): string {
if (!reward) return 'text-common'; if (!reward) return 'text-common';
const probability = reward.probability; const probability = reward.probability;
if (probability < 0.01) return 'text-mythic'; if (probability < 0.01) return 'text-mythic';
if (probability < 0.05) return 'text-legendary'; if (probability < 0.05) return 'text-legendary';
if (probability < 0.10) return 'text-epic'; if (probability < 0.1) return 'text-epic';
if (probability < 0.20) return 'text-rare'; if (probability < 0.2) return 'text-rare';
if (probability < 0.40) return 'text-uncommon'; if (probability < 0.4) return 'text-uncommon';
return 'text-common'; return 'text-common';
} }
getRewardClass(): string { getRewardClass(): string {
if (!this.wonReward || !this.lootbox) return ''; if (!this.wonReward || !this.lootbox) return '';
return this.wonReward.value > (this.lootbox.price || 0) ? 'text-emerald' : 'text-accent-red'; return this.wonReward.value > (this.lootbox.price || 0) ? 'text-emerald' : 'text-accent-red';
} }
} }

View file

@ -8,15 +8,21 @@
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
.card { .card {
transition: transform 0.3s ease, box-shadow 0.3s ease; transition:
transform 0.3s ease,
box-shadow 0.3s ease;
} }
.card:hover { .card:hover {
transform: translateY(-5px); transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
} }

View file

@ -1,48 +1,60 @@
<app-navbar></app-navbar> <app-navbar></app-navbar>
<div class="container mx-auto px-4 py-6"> <div class="container mx-auto px-4 py-6">
<h1 class="text-3xl font-bold text-white mb-6">Lootboxen</h1> <h1 class="text-3xl font-bold text-white mb-6">Lootboxen</h1>
<div style="color:lime">isLoading: {{ isLoading }} | error: {{ error }} | lootboxes: {{ lootboxes.length }}</div> <div style="color: lime">
isLoading: {{ isLoading }} | error: {{ error }} | lootboxes: {{ lootboxes.length }}
</div>
<div *ngIf="isLoading" class="flex justify-center"> <div *ngIf="isLoading" class="flex justify-center">
<div class="loader"></div> <div class="loader"></div>
</div> </div>
<div *ngIf="error" class="bg-red-500 text-white p-4 rounded mb-6"> <div *ngIf="error" class="bg-red-500 text-white p-4 rounded mb-6">
{{ error }} {{ error }}
</div> </div>
<div *ngIf="!isLoading && !error" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div *ngIf="!isLoading && !error" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div *ngFor="let lootbox of lootboxes" class="card bg-deep-blue-light rounded-lg overflow-hidden shadow-lg hover:shadow-xl transition-shadow"> <div
*ngFor="let lootbox of lootboxes"
class="card bg-deep-blue-light rounded-lg overflow-hidden shadow-lg hover:shadow-xl transition-shadow"
>
<div class="relative"> <div class="relative">
<img [src]="getBoxImage(lootbox.id)" [alt]="lootbox.name" class="w-full h-48 object-cover"> <img
[src]="getBoxImage(lootbox.id)"
[alt]="lootbox.name"
class="w-full h-48 object-cover"
/>
<div class="absolute top-2 right-2 bg-deep-blue px-2 py-1 rounded-full text-white"> <div class="absolute top-2 right-2 bg-deep-blue px-2 py-1 rounded-full text-white">
{{ lootbox.price | currency:'EUR' }} {{ lootbox.price | currency: 'EUR' }}
</div> </div>
</div> </div>
<div class="p-4"> <div class="p-4">
<h2 class="text-xl font-bold text-white mb-2">{{ lootbox.name }}</h2> <h2 class="text-xl font-bold text-white mb-2">{{ lootbox.name }}</h2>
<div class="mb-4"> <div class="mb-4">
<h3 class="text-lg font-semibold text-white mb-2">Mögliche Gewinne:</h3> <h3 class="text-lg font-semibold text-white mb-2">Mögliche Gewinne:</h3>
<ul class="space-y-2"> <ul class="space-y-2">
<li *ngFor="let reward of lootbox.rewards" class="flex justify-between"> <li *ngFor="let reward of lootbox.rewards" class="flex justify-between">
<span [ngClass]="getRarityClass(reward.probability)">{{ reward.value | currency:'EUR' }}</span> <span [ngClass]="getRarityClass(reward.probability)">{{
reward.value | currency: 'EUR'
}}</span>
<span class="text-white">{{ formatProbability(reward.probability) }}</span> <span class="text-white">{{ formatProbability(reward.probability) }}</span>
</li> </li>
</ul> </ul>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<button <button
(click)="openLootbox(lootbox.id)" (click)="openLootbox(lootbox.id)"
class="button-primary w-full py-2 rounded font-semibold"> class="button-primary w-full py-2 rounded font-semibold"
>
Öffnen Öffnen
</button> </button>
</div> </div>
</div> </div>
<div class="px-4 pb-4"> <div class="px-4 pb-4">
<div class="text-sm text-text-secondary"> <div class="text-sm text-text-secondary">
<p>Fairness garantiert - Alle Ergebnisse werden transparent berechnet.</p> <p>Fairness garantiert - Alle Ergebnisse werden transparent berechnet.</p>
@ -50,4 +62,4 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -11,7 +11,7 @@ import { timeout } from 'rxjs';
standalone: true, standalone: true,
imports: [CommonModule, NavbarComponent], imports: [CommonModule, NavbarComponent],
templateUrl: './lootbox-selection.component.html', templateUrl: './lootbox-selection.component.html',
styleUrls: ['./lootbox-selection.component.css'] styleUrls: ['./lootbox-selection.component.css'],
}) })
export default class LootboxSelectionComponent implements OnInit { export default class LootboxSelectionComponent implements OnInit {
lootboxes: LootBox[] = []; lootboxes: LootBox[] = [];
@ -22,68 +22,72 @@ export default class LootboxSelectionComponent implements OnInit {
fallbackLootboxes: LootBox[] = [ fallbackLootboxes: LootBox[] = [
{ {
id: 1, id: 1,
name: "Basic LootBox", name: 'Basic LootBox',
price: 2.00, price: 2.0,
rewards: [ rewards: [
{ {
id: 1, id: 1,
value: 0.50, value: 0.5,
probability: 0.70 probability: 0.7,
}, },
{ {
id: 5, id: 5,
value: 5.00, value: 5.0,
probability: 0.30 probability: 0.3,
} },
] ],
}, },
{ {
id: 2, id: 2,
name: "Premium LootBox", name: 'Premium LootBox',
price: 5.00, price: 5.0,
rewards: [ rewards: [
{ {
id: 4, id: 4,
value: 2.00, value: 2.0,
probability: 0.60 probability: 0.6,
}, },
{ {
id: 5, id: 5,
value: 5.00, value: 5.0,
probability: 0.30 probability: 0.3,
}, },
{ {
id: 6, id: 6,
value: 15.00, value: 15.0,
probability: 0.10 probability: 0.1,
} },
] ],
}, },
{ {
id: 3, id: 3,
name: "Legendäre LootBox", name: 'Legendäre LootBox',
price: 15.00, price: 15.0,
rewards: [ rewards: [
{ {
id: 4, id: 4,
value: 2.00, value: 2.0,
probability: 0.60 probability: 0.6,
}, },
{ {
id: 5, id: 5,
value: 5.00, value: 5.0,
probability: 0.30 probability: 0.3,
}, },
{ {
id: 6, id: 6,
value: 15.00, value: 15.0,
probability: 0.10 probability: 0.1,
} },
] ],
} },
]; ];
constructor(private lootboxService: LootboxService, private router: Router, private cdr: ChangeDetectorRef) {} constructor(
private lootboxService: LootboxService,
private router: Router,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void { ngOnInit(): void {
this.loadLootboxes(); this.loadLootboxes();
@ -91,23 +95,24 @@ export default class LootboxSelectionComponent implements OnInit {
loadLootboxes(): void { loadLootboxes(): void {
this.isLoading = true; this.isLoading = true;
this.lootboxService.getAllLootBoxes().pipe( this.lootboxService
timeout(5000) .getAllLootBoxes()
).subscribe({ .pipe(timeout(5000))
next: (data) => { .subscribe({
console.log('Received lootboxes:', data); next: (data) => {
this.lootboxes = data; console.log('Received lootboxes:', data);
this.isLoading = false; this.lootboxes = data;
this.cdr.detectChanges(); this.isLoading = false;
}, this.cdr.detectChanges();
error: (err) => { },
this.error = 'Konnte keine Verbindung zum Backend herstellen. Zeige Demo-Daten.'; error: (err) => {
this.lootboxes = this.fallbackLootboxes; this.error = 'Konnte keine Verbindung zum Backend herstellen. Zeige Demo-Daten.';
this.isLoading = false; this.lootboxes = this.fallbackLootboxes;
this.cdr.detectChanges(); this.isLoading = false;
console.error('Failed to load lootboxes:', err); this.cdr.detectChanges();
} console.error('Failed to load lootboxes:', err);
}); },
});
} }
getBoxImage(id: number): string { getBoxImage(id: number): string {
@ -131,4 +136,4 @@ export default class LootboxSelectionComponent implements OnInit {
formatProbability(probability: number): string { formatProbability(probability: number): string {
return (probability * 100).toFixed(0) + '%'; return (probability * 100).toFixed(0) + '%';
} }
} }

View file

@ -10,14 +10,12 @@ export class LootboxService {
private http = inject(HttpClient); private http = inject(HttpClient);
getAllLootBoxes(): Observable<LootBox[]> { getAllLootBoxes(): Observable<LootBox[]> {
return this.http return this.http.get<LootBox[]>('/backend/lootboxes', { responseType: 'json' }).pipe(
.get<LootBox[]>('/backend/lootboxes', { responseType: 'json' }) catchError((error) => {
.pipe( console.error('Get lootboxes error:', error);
catchError((error) => { throw error;
console.error('Get lootboxes error:', error); })
throw error; );
})
);
} }
purchaseLootBox(lootBoxId: number): Observable<Reward> { purchaseLootBox(lootBoxId: number): Observable<Reward> {
@ -30,4 +28,4 @@ export class LootboxService {
}) })
); );
} }
} }

View file

@ -9,4 +9,4 @@ export interface LootBox {
name: string; name: string;
price: number; price: number;
rewards: Reward[]; rewards: Reward[];
} }