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
348 lines
15 KiB
TypeScript
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;
|
|
}
|