Files
Luncher/server/src/confetti.ts
T
batmanisko e281615cc3
CI / Generate TypeScript types (push) Successful in 11s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 1m52s
CI / Build and push Docker image (push) Successful in 51s
CI / Notify (push) Successful in 2s
feat: spin the wheel, objednavani scrape menu, historie cen
2026-09-23 15:41:15 +02:00

348 lines
15 KiB
TypeScript

import crypto from "crypto";
import axios from "axios";
import getStorage from "./storage";
const storage = getStorage();
/**
* Postavy Umamusume pro konfety kola štěstí (styl Umamusume): chibi obrázky (případně
* portrét s `round: true`) a typická hláška.
* Zdroj: Umamusume Wiki (umamusume.fandom.com) — obrázky se do repozitáře neukládají,
* server je při prvním použití stáhne a nacachuje ve storage (viz {@link getConfettiImage}).
*/
export const UMA_CHARACTERS: { name: string, quote?: string, round?: boolean, images: string[], icon?: string }[] = [
{
name: "Air Groove",
quote: "Just as my esteemed mother once did, I must train to perfection and lead others along my path!",
images: [
'https://static.wikia.nocookie.net/umamusume/images/c/c3/Air_Groove_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/f/f9/Air_Groove_Original_Chibi_1.png',
],
},
{
name: "Bamboo Memory",
quote: "Let's keep the pecking order nice and neat. We don't want anything bad to happen, right?",
images: [
'https://static.wikia.nocookie.net/umamusume/images/c/c5/Bamboo_Memory_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/e/e7/Bamboo_Memory_Original_Chibi_2.png',
],
},
{
name: "Cesario",
quote: "I will not give up this Tiara. It's time for me to show the way.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/7/74/Cesario_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/a/ad/Cesario_Original_Chibi_1.png',
],
},
{
name: "El Condor Pasa",
quote: "Soaring towards victory with the heart of a wrestler! El's the world's strooongest!",
images: [
'https://static.wikia.nocookie.net/umamusume/images/7/72/El_Condor_Pasa_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/c/c0/El_Condor_Pasa_Original_Chibi_2.png',
],
},
{
name: "Gold Ship",
quote: "So long as it's fun, right? Right?!",
images: [
'https://static.wikia.nocookie.net/umamusume/images/8/84/Gold_Ship_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/d/db/Gold_Ship_Original_Chibi_1.png',
],
},
{
name: "Manhattan Cafe",
quote: "I'm... right here, with someone you can't see.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/8/8f/Manhattan_Cafe_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/d/de/Manhattan_Cafe_Original_Chibi_2.png',
],
},
{
name: "Matikanetannhauser",
quote: "Y'know, I kinda get the feeling I can really do it this time!",
images: [
'https://static.wikia.nocookie.net/umamusume/images/6/6b/Matikanetannhauser_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/9/90/Matikanetannhauser_Original_Chibi_1.png',
],
},
{
name: "Mejiro Bright",
quote: "Oh, is it time for my race already? Well then, I suppose I shall get ready.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/3/3d/Mejiro_Bright_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/c/cc/Mejiro_Bright_Original_Chibi_1.png',
],
},
{
name: "Nakayama Festa",
quote: "Cold… is that so? When my passion heats up it'll be hot enough to burn you.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/1/16/Nakayama_Festa_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/5/57/Nakayama_Festa_Original_Chibi_2.png',
],
},
{
name: "Neo Universe",
quote: "Neo Universe would like to request a friendly \"ENCOUNTER\". In other words, I'm in your hands.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/3/34/Neo_Universe_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/4/40/Neo_Universe_Original_Chibi_2.png',
],
},
{
name: "Phalaenopsis",
quote: "Frail health, poor form—what are they to me? Legs that dull at such trifles have no business claiming victory.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/6/65/Phalaenopsis_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/9/95/Phalaenopsis_Original_Chibi_1.png',
],
},
{
name: "Satono Diamond",
quote: "Everyone believes in my ability to win. I shall shine on like a brilliant diamond for them!",
images: [
'https://static.wikia.nocookie.net/umamusume/images/3/3b/Satono_Diamond_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/9/9d/Satono_Diamond_Original_Chibi_2.png',
],
},
{
name: "Silence Suzuka",
quote: "I just want to see... that scenery from the lead.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/1/1f/Silence_Suzuka_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/d/df/Silence_Suzuka_Original_Chibi_2.png',
],
},
{
name: "Special Week",
images: [
'https://static.wikia.nocookie.net/umamusume/images/4/4f/Special_Week_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/6/6c/Special_Week_Original_Chibi_1.png',
],
// Portrét pro odznak „jedlíka" (chibi je v malé velikosti nečitelný)
icon: 'https://static.wikia.nocookie.net/umamusume/images/3/3a/Special_Week_%28icon%29.png/revision/latest/scale-to-width-down/128',
},
{
name: "Tokai Teio",
quote: "Here begins the legend of the invincible Teio!",
images: [
'https://static.wikia.nocookie.net/umamusume/images/3/36/Tokai_Teio_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/9/9e/Tokai_Teio_Original_Chibi_2.png',
],
},
{
name: "Tosen Jordan",
quote: "My dream is... actually, serious topics are NG~☆ Anyways, look at my nails! Aren't they cool?",
images: [
'https://static.wikia.nocookie.net/umamusume/images/3/3e/Tosen_Jordan_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/7/7b/Tosen_Jordan_Original_Chibi_2.png',
],
},
{
name: "Yaeno Muteki",
images: [
'https://static.wikia.nocookie.net/umamusume/images/9/9d/Yaeno_Muteki_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/d/d7/Yaeno_Muteki_Original_Chibi_1.png',
],
},
// Postavy bez chibi obrázku na wiki — čtvercový portrét (ikona), klient ho zobrazí jako kulatou samolepku
{
name: "Kitasan Black",
quote: "I'll give it a go! You'll never know unless you try, right?",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/c/c8/Kitasan_Black_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Oguri Cap",
quote: "I'm going to win this for the folks back home... But first, sustenance.",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/5/57/Oguri_Cap_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Haru Urara",
quote: "I won't let losing get me down! Urara, la la la! ♪",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/7/7b/Haru_Urara_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Mejiro McQueen",
quote: "I'll bring honor and glory to the Mejiro family!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/1/15/Mejiro_McQueen_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Rice Shower",
quote: "Rice only brings misfortune to everyone... I really... really hope I can change that!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/0/07/Rice_Shower_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Daiwa Scarlet",
quote: "Second-best won't cut it. Everything I do is for the sake of being number one!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/d/d7/Daiwa_Scarlet_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Vodka",
quote: "I've just gotta be the coolest to ever do it!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/0/0d/Vodka_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Agnes Tachyon",
quote: "Life is an experiment, and we its guinea pigs. You, me, the other Umamusume—everyone!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/9/9d/Agnes_Tachyon_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Twin Turbo",
quote: "Turbo's my name, and speed's my game! Let's race, right now! Hey! You listenin' to me?!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/9/97/Twin_Turbo_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Symboli Rudolf",
quote: "Fortune smiles upon the brave. Let us create a world where all Umamusume can live in bliss.",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/5/56/Symboli_Rudolf_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Nice Nature",
quote: "Yup, I'm happy with a third place finish—it's all an extra like me can ask for.",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/7/77/Nice_Nature_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Maruzensky",
quote: "The track's like a dance floor, and I'm feelin' groovy today!♪",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/b/b4/Maruzensky_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Grass Wonder",
quote: "Noble and gallant, I march on... towards my ambitions.",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/d/d0/Grass_Wonder_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Mihono Bourbon",
quote: "Target acquired. Commencing Operation: Triple Crown.",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/3/37/Mihono_Bourbon_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Curren Chan",
quote: "New day, same adorable Curren! ☆ Spread the word with #CurrenChan!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/9/91/Curren_Chan_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Super Creek",
quote: "I can spoil you as much as you'd like. Aww, there, there. ♪",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/5/5e/Super_Creek_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
];
/** Po neúspěšném stažení obrázku se další pokus smí provést až po této době. */
const RETRY_AFTER_MS = 10 * 60 * 1000;
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
type CachedImage = { contentType: string, data: string };
/** Stabilní ID obrázku z jeho URL — nemění se při úpravách seznamu (prohlížeč obrázky cachuje natrvalo). */
const imageId = (url: string) => crypto.createHash('sha1').update(url).digest('hex').slice(0, 16);
const imageKey = (url: string) => `confetti_img_${imageId(url)}`;
const imageUrl = (url: string) => `/api/confetti/image/${imageId(url)}`;
/** Povolené obrázky: ID → zdrojová URL (nic jiného server nestáhne). */
const ALLOWED_IMAGES = new Map(UMA_CHARACTERS.flatMap(c => c.icon ? [...c.images, c.icon] : c.images).map(url => [imageId(url), url]));
const failedAt = new Map<string, number>();
const inFlight = new Map<string, Promise<CachedImage | undefined>>();
/** Seznam postav pro klienta — obrázky vedou na cachující proxy tohoto serveru. */
export function getConfettiCharacters() {
return UMA_CHARACTERS.map(c => ({
name: c.name,
...(c.quote && { quote: c.quote }),
...(c.round && { round: true }),
images: c.images.map(imageUrl),
...(c.icon && { icon: imageUrl(c.icon) }),
}));
}
/**
* Vrátí obrázek konfet dle jeho ID. Poprvé ho stáhne a uloží do storage, další
* požadavky (i při výpadku internetu) obslouží z cache. Stahují se jen URL ze seznamu výše.
*/
export async function getConfettiImage(id: string): Promise<{ contentType: string, data: Buffer } | undefined> {
const url = ALLOWED_IMAGES.get(id);
if (!url) return undefined;
const key = imageKey(url);
const cached = await storage.getData<CachedImage>(key);
if (cached?.data) {
return { contentType: cached.contentType, data: Buffer.from(cached.data, 'base64') };
}
const lastFailure = failedAt.get(id);
if (lastFailure && Date.now() - lastFailure < RETRY_AFTER_MS) return undefined;
let pending = inFlight.get(id);
if (!pending) {
pending = downloadImage(url, key)
.catch(e => {
console.warn(`Konfety: obrázek ${url} se nepodařilo stáhnout — ${e?.message ?? e}`);
failedAt.set(id, Date.now());
return undefined;
})
.finally(() => inFlight.delete(id));
inFlight.set(id, pending);
}
const image = await pending;
return image && { contentType: image.contentType, data: Buffer.from(image.data, 'base64') };
}
async function downloadImage(url: string, key: string): Promise<CachedImage> {
const res = await axios.get<ArrayBuffer>(url, { responseType: 'arraybuffer', timeout: 10_000, maxContentLength: MAX_IMAGE_BYTES });
const contentType = String(res.headers['content-type'] ?? '');
if (!contentType.startsWith('image/')) {
throw new Error(`Neočekávaný typ obsahu ${contentType}`);
}
const image = { contentType, data: Buffer.from(res.data).toString('base64') };
await storage.setData<CachedImage>(key, image);
return image;
}