Compare commits

..

7 Commits

19 changed files with 340 additions and 519 deletions

View File

@@ -3,11 +3,14 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
@@ -26,20 +29,23 @@ import (
func main() { func main() {
var ( var (
dataDir string // public site
tokenFile string
// http api options
addr string addr string
route string dataDir string
public string
// discord
tokenFile string
apiRoute string
pubkey string pubkey string
// logging options // logging
level slog.Level level slog.Level
textfmt string textfmt string
) )
flag.StringVar(&addr, "http", ":80", "`address` to bind HTTP server")
flag.StringVar(&dataDir, "data", "", "`dir`ectory containing exported json data") flag.StringVar(&dataDir, "data", "", "`dir`ectory containing exported json data")
flag.StringVar(&public, "public", "", "`dir`ectory containing the website to serve")
flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token") flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token")
flag.StringVar(&addr, "http", "", "`address` to bind HTTP API server") flag.StringVar(&apiRoute, "route", "/interactions/callback", "`path` to serve Discord HTTP API calls")
flag.StringVar(&route, "route", "/interactions/callback", "`path` to serve HTTP API calls")
flag.StringVar(&pubkey, "key", "", "Discord public key") flag.StringVar(&pubkey, "key", "", "Discord public key")
flag.TextVar(&level, "log", slog.LevelInfo, "slog logging `level`") flag.TextVar(&level, "log", slog.LevelInfo, "slog logging `level`")
flag.StringVar(&textfmt, "log-format", "text", "slog logging `format`, text or json") flag.StringVar(&textfmt, "log-format", "text", "slog logging `format`, text or json")
@@ -57,6 +63,16 @@ func main() {
} }
slog.SetDefault(slog.New(lh)) slog.SetDefault(slog.New(lh))
stat, err := os.Stat(public)
if err != nil {
slog.Error("public", slog.Any("err", err))
os.Exit(1)
}
if !stat.IsDir() {
slog.Error("public", slog.String("err", "not a directory"))
os.Exit(1)
}
skills, err := loadSkills(filepath.Join(dataDir, "skill.json")) skills, err := loadSkills(filepath.Join(dataDir, "skill.json"))
slog.Info("loaded skills", slog.Int("count", len(skills))) slog.Info("loaded skills", slog.Int("count", len(skills)))
groups, err2 := loadSkillGroups(filepath.Join(dataDir, "skill-group.json")) groups, err2 := loadSkillGroups(filepath.Join(dataDir, "skill-group.json"))
@@ -87,33 +103,52 @@ func main() {
r.SelectMenuComponent("/swap", skillSrv.menu) r.SelectMenuComponent("/swap", skillSrv.menu)
r.ButtonComponent("/swap/{id}", skillSrv.button) r.ButtonComponent("/swap/{id}", skillSrv.button)
}) })
opts := []bot.ConfigOpt{bot.WithDefaultGateway(), bot.WithEventListeners(r)}
if addr != "" {
if pubkey == "" {
slog.Error("Discord public key must be provided when using HTTP API")
os.Exit(1)
}
opts = append(opts, bot.WithHTTPServerConfigOpts(pubkey,
httpserver.WithAddress(addr),
httpserver.WithURL(route),
))
}
slog.Info("connect", slog.String("disgo", disgo.Version)) slog.Info("connect", slog.String("disgo", disgo.Version))
client, err := disgo.New(string(token), opts...) client, err := disgo.New(string(token), bot.WithDefaultGateway(), bot.WithEventListeners(r))
if err != nil { if err != nil {
slog.Error("building bot", slog.Any("err", err)) slog.Error("building bot", slog.Any("err", err))
os.Exit(1) os.Exit(1)
} }
mux := http.NewServeMux()
mux.Handle("GET /", http.FileServerFS(os.DirFS(public)))
if pubkey != "" {
pk, err := hex.DecodeString(pubkey)
if err != nil {
slog.Error("pubkey", slog.Any("err", err))
os.Exit(1)
}
mux.Handle(apiRoute, httpserver.HandleInteraction(httpserver.DefaultVerifier{}, pk, slog.Default(), client.EventManager.HandleHTTPEvent))
slog.Info("Discord HTTP API enabled", slog.String("pubkey", pubkey))
}
l, err := net.Listen("tcp", addr)
if err != nil {
slog.Error("listen", slog.String("addr", addr), slog.Any("err", err))
os.Exit(1)
}
srv := http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
BaseContext: func(l net.Listener) context.Context { return ctx },
}
go func() {
slog.Info("HTTP", slog.Any("addr", l.Addr()))
err := srv.Serve(l)
if err == http.ErrServerClosed {
return
}
slog.Error("HTTP server closed", slog.Any("err", err))
}()
if err := handler.SyncCommands(client, commands, nil, rest.WithCtx(ctx)); err != nil { if err := handler.SyncCommands(client, commands, nil, rest.WithCtx(ctx)); err != nil {
slog.Error("syncing commands", slog.Any("err", err)) slog.Error("syncing commands", slog.Any("err", err))
os.Exit(1) os.Exit(1)
} }
if addr != "" { if pubkey != "" {
slog.Info("start HTTP server", slog.String("address", addr), slog.String("route", route)) slog.Info("start HTTP server", slog.String("address", addr), slog.String("route", apiRoute))
if err := client.OpenHTTPServer(); err != nil { if err := client.OpenHTTPServer(); err != nil {
slog.Error("starting HTTP server", slog.Any("err", err)) slog.Error("starting HTTP server", slog.Any("err", err))
stop() stop()
@@ -131,6 +166,10 @@ func main() {
ctx, stop = context.WithTimeout(context.Background(), 5*time.Second) ctx, stop = context.WithTimeout(context.Background(), 5*time.Second)
defer stop() defer stop()
client.Close(ctx) client.Close(ctx)
if err := srv.Shutdown(ctx); err != nil {
slog.Error("HTTP API shutdown", slog.Any("err", err))
os.Exit(1)
}
} }
var commands = []discord.ApplicationCommandCreate{ var commands = []discord.ApplicationCommandCreate{

View File

@@ -1,8 +1,8 @@
{ {
"useTabs": true, "useTabs": true,
"singleQuote": true, "singleQuote": true,
"trailingComma": "none", "trailingComma": "all",
"printWidth": 100, "printWidth": 130,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [ "overrides": [
{ {

View File

@@ -22,8 +22,8 @@ export default defineConfig(
rules: { rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off' 'no-undef': 'off',
} },
}, },
{ {
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
@@ -32,8 +32,8 @@ export default defineConfig(
projectService: true, projectService: true,
extraFileExtensions: ['.svelte'], extraFileExtensions: ['.svelte'],
parser: ts.parser, parser: ts.parser,
svelteConfig svelteConfig,
} },
} },
} },
); );

View File

@@ -11,6 +11,7 @@
"@eslint/compat": "^2.0.3", "@eslint/compat": "^2.0.3",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
@@ -1134,6 +1135,16 @@
"@sveltejs/kit": "^2.0.0" "@sveltejs/kit": "^2.0.0"
} }
}, },
"node_modules/@sveltejs/adapter-static": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@sveltejs/kit": "^2.0.0"
}
},
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.55.0", "version": "2.55.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",

View File

@@ -19,6 +19,7 @@
"@eslint/compat": "^2.0.3", "@eslint/compat": "^2.0.3",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",

View File

@@ -1,25 +1,38 @@
<script lang="ts"> <script lang="ts">
import { character } from '$lib/data/character' import { character } from '$lib/data/character';
import type { ClassValue } from 'svelte/elements';
interface Props { interface Props {
id: string id: string;
value: number value: number;
label?: string label?: string;
region?: keyof typeof character class?: ClassValue | null;
required?: boolean optionClass?: ClassValue | null;
labelClass?: ClassValue | null;
region?: keyof typeof character;
required?: boolean;
} }
let { id, value = $bindable(), label, region = 'global', required = false }: Props = $props() let {
id,
value = $bindable(),
label,
class: className,
optionClass,
labelClass,
region = 'global',
required = false,
}: Props = $props();
</script> </script>
{#if label} {#if label}
<label for={id}>{label}</label> <label for={id} class={labelClass}>{label}</label>
{/if} {/if}
<select id={id} bind:value={value} required={required}> <select {id} class={className} bind:value {required}>
{#if !required} {#if !required}
<option value=0></option> <option value="0" class={optionClass}></option>
{/if} {/if}
{#each character[region] as c} {#each character[region] as c}
<option value={c.chara_id}>{c.name}</option> <option value={c.chara_id} class={optionClass}>{c.name}</option>
{/each} {/each}
</select> </select>

View File

@@ -1,5 +1,5 @@
import type { RegionalName } from '$lib/regional-name' import type { RegionalName } from '$lib/regional-name';
import globalJSON from '../../../../global/character.json' import globalJSON from '../../../../global/character.json';
/** /**
* Character definitions. * Character definitions.
@@ -8,16 +8,19 @@ export interface Character {
/** /**
* Character ID. * Character ID.
*/ */
chara_id: number chara_id: number;
/** /**
* Regional name of the character. * Regional name of the character.
* E.g., Special Week for Global, or スペシャルウィーク for JP. * E.g., Special Week for Global, or スペシャルウィーク for JP.
*/ */
name: string name: string;
} }
export const character = { export const character = {
global: globalJSON as Character[], global: globalJSON as Character[],
} };
export const charaNames = globalJSON.reduce((m, c) => m.set(c.chara_id, {en: c.name}), new Map<Character['chara_id'], RegionalName>()); export const charaNames = globalJSON.reduce(
(m, c) => m.set(c.chara_id, { en: c.name }),
new Map<Character['chara_id'], RegionalName>(),
);

View File

@@ -1,4 +1,5 @@
import globalJSON from '../../../../global/conversation.json' import type { RegionalName } from '$lib/regional-name';
import globalJSON from '../../../../global/conversation.json';
/** /**
* Lobby conversation data. * Lobby conversation data.
@@ -41,8 +42,44 @@ export interface Conversation {
export const conversation = { export const conversation = {
global: globalJSON as Conversation[], global: globalJSON as Conversation[],
} };
export const byChara = { export const byChara = {
global: globalJSON.reduce((m, c) => m.set(c.chara_id, (m.get(c.chara_id) ?? []).concat(c as Conversation)), new Map<Conversation['chara_id'], Conversation[]>()), global: globalJSON.reduce(
(m, c) => m.set(c.chara_id, (m.get(c.chara_id) ?? []).concat(c as Conversation)),
new Map<Conversation['chara_id'], Conversation[]>(),
),
};
export const locations: Record<Conversation['location'], { name: RegionalName; group: 1 | 2 | 3 | 4 | 5 }> = {
110: { name: { en: 'right side front' }, group: 1 },
120: { name: { en: 'right side front' }, group: 1 },
130: { name: { en: 'right side front' }, group: 1 },
210: { name: { en: 'left side table' }, group: 2 },
220: { name: { en: 'left side table' }, group: 2 },
310: { name: { en: 'center back seat' }, group: 3 },
410: { name: { en: 'center posters' }, group: 4 },
420: { name: { en: 'center posters' }, group: 4 },
430: { name: { en: 'center posters' }, group: 4 },
510: { name: { en: 'left side school map' }, group: 5 },
520: { name: { en: 'left side school map' }, group: 5 },
530: { name: { en: 'left side school map' }, group: 5 },
};
function locCharas(convos: Conversation[], locGroup: 1 | 2 | 3 | 4 | 5) {
const m = convos
.filter((c) => locations[c.location].group === locGroup)
.flatMap((c) => [c.chara_1, c.chara_2, c.chara_3].filter((x) => x != null))
.reduce((m, id) => m.set(id, 1 + (m.get(id) ?? 0)), new Map<number, number>());
return [...m].toSorted((a, b) => b[1] - a[1]); // descending
} }
export const groupPopulars = {
global: {
1: locCharas(conversation.global, 1),
2: locCharas(conversation.global, 2),
3: locCharas(conversation.global, 3),
4: locCharas(conversation.global, 4),
5: locCharas(conversation.global, 5),
},
};

View File

@@ -3,5 +3,5 @@
* Currently English is the only supported language. * Currently English is the only supported language.
*/ */
export interface RegionalName { export interface RegionalName {
en: string en: string;
} }

View File

@@ -7,9 +7,7 @@ describe('Welcome.svelte', () => {
it('renders greetings for host and guest', async () => { it('renders greetings for host and guest', async () => {
render(Welcome, { host: 'SvelteKit', guest: 'Vitest' }); render(Welcome, { host: 'SvelteKit', guest: 'Vitest' });
await expect await expect.element(page.getByRole('heading', { level: 1 })).toHaveTextContent('Hello, SvelteKit!');
.element(page.getByRole('heading', { level: 1 }))
.toHaveTextContent('Hello, SvelteKit!');
await expect.element(page.getByText('Hello, Vitest!')).toBeInTheDocument(); await expect.element(page.getByText('Hello, Vitest!')).toBeInTheDocument();
}); });
}); });

View File

@@ -7,22 +7,24 @@
<svelte:head><link rel="icon" href={favicon} /></svelte:head> <svelte:head><link rel="icon" href={favicon} /></svelte:head>
<nav class="flex min-w-full p-4 mb-4 shadow-md"> <div class="flex h-screen flex-col">
<span class="hidden md:inline flex-1"> <nav class="mb-4 flex min-w-full bg-mist-300 p-4 shadow-md dark:bg-mist-900">
<a href="/" class="text-7xl">Zenno Rob Roy</a> <span class="hidden flex-1 md:inline">
<a href="/" class="text-4xl">Zenno Rob Roy</a>
</span> </span>
<span class="flex-1 text-center"> <span class="flex-1 text-center">
<a href="/" class="md:hidden block mx-8 my-1 font-semibold">Zenno Rob Roy</a> <a href="/" class="mx-8 my-1 block font-semibold md:hidden">Zenno Rob Roy</a>
<a href="/inherit" class="inline-block mx-8 my-1">Inheritance Chance</a> <a href="/inherit" class="mx-8 my-1 inline-block">Inheritance Chance</a>
<a href="/spark" class="inline-block mx-8 my-1">Spark Chance</a> <a href="/spark" class="mx-8 my-1 inline-block">Spark Chance</a>
<a href="/vet" class="inline-block mx-8 my-1">My Veterans</a> <a href="/vet" class="mx-8 my-1 inline-block">My Veterans</a>
<a href="/convo" class="inline-block mx-8 my-1">Lobby Conversations</a> <a href="/convo" class="mx-8 my-1 inline-block">Lobby Conversations</a>
</span> </span>
</nav> </nav>
<div class="md:min-w-7xl md:max-w-7xl md:m-auto"> <div class="mx-4 grow lg:m-auto lg:max-w-7xl lg:min-w-7xl">
{@render children()} {@render children()}
</div> </div>
<footer class="p-4 mt-32 inset-x-0 bottom-0 border-t text-center text-[14px]"> <footer class="inset-x-0 bottom-0 mt-8 border-t bg-mist-300 p-4 text-center text-sm md:mt-20 dark:border-none dark:bg-mist-900">
Umamusume: Pretty Derby tools by <a href="https://zephyrtronium.date/">zephyrtronium</a>.<br> Umamusume: Pretty Derby tools by <a href="https://zephyrtronium.date/" target="_blank" rel="noopener noreferrer">zephyrtronium</a>.<br />
All data is generated from the game's local database. All game data is auto-generated from the <a href="https://git.sunturtle.xyz/zephyr/horse/src/branch/main/doc/README.md" target="_blank" rel="noopener noreferrer">game's local database</a>.
</footer> </footer>
</div>

View File

@@ -0,0 +1 @@
export const prerender = true;

View File

@@ -1,11 +1,34 @@
<script lang="ts"> <h1 class="m-8 text-center text-7xl">Zenno Rob Roy</h1>
import CharaPick from "$lib/CharaPick.svelte"; <p>She's read all about Umamusume, and she's always happy to share her knowledge and give recommendations!</p>
<h2 class="mt-8 mb-4 text-4xl">Tools</h2>
let selChara = $state(0); <ul class="list-disc pl-4">
</script> <li>
<a href="/inherit">Inheritance Chance</a><i>Not yet implemented</i> — Given a legacy, calculate the probability distribution
<h1>Welcome to SvelteKit</h1> of activation counts for each spark.
<CharaPick id="test-chara" bind:value={selChara} /> </li>
<p>selected character is id {selChara}</p> <li>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> <a href="/spark">Spark Chance</a><i>Not yet implemented</i> — Given a legacy, calculate the chance of generating each spark if
<p>Lorem ipsum (/ ˌ l ɔː. r ə m ˈ ɪ p. s ə m/ LOR-əm IP-səm) is a dummy or placeholder text commonly used in graphic design, publishing, and web development. It is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin. The first two words are the truncation of dolorem ipsum ("pain itself"). Lorem ipsum's purpose is to permit a page layout to be designed, independently of the copy that will subsequently populate it, or to demonstrate various fonts of a typeface without meaningful text that could be distracting. Versions of the Lorem ipsum text have been used in typesetting since the 1960s, when advertisements for Letraset transfer sheets popularized it. Lorem ipsum was introduced to the digital world in the mid-1980s, when Aldus employed it in graphic and word-processing templates for its desktop publishing program PageMaker. Other popular word processors, including Pages and Microsoft Word, have since adopted Lorem ipsum, as have many LaTeX packages, web content</p> you fulfill the conditions to do so, and the distribution of total spark counts.
</li>
<li>
<a href="/vet">My Veterans</a><i>Not yet implemented</i> — Set up and track your veterans for Zenno Rob Roy's inspiration and
spark calculators.
</li>
<li>
<a href="/convo">Lobby Conversations</a> — Check participants in lobby conversations and get recommendations on unlocking them quickly.
</li>
</ul>
<h2 class="mt-8 mb-4 text-4xl">About</h2>
<p>Tools to fill some gaps I've felt in Umamusume optimization.</p>
<p>This site is very under construction. To demonstrate just how under construction it is, here is lorem ipsum:</p>
<p>
Lorem ipsum (/ ˌ l ɔː. r ə m ˈ ɪ p. s ə m/ LOR-əm IP-səm) is a dummy or placeholder text commonly used in graphic design,
publishing, and web development. It is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by
the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin.
The first two words are the truncation of dolorem ipsum ("pain itself"). Lorem ipsum's purpose is to permit a page layout to be
designed, independently of the copy that will subsequently populate it, or to demonstrate various fonts of a typeface without
meaningful text that could be distracting. Versions of the Lorem ipsum text have been used in typesetting since the 1960s, when
advertisements for Letraset transfer sheets popularized it. Lorem ipsum was introduced to the digital world in the mid-1980s,
when Aldus employed it in graphic and word-processing templates for its desktop publishing program PageMaker. Other popular word
processors, including Pages and Microsoft Word, have since adopted Lorem ipsum, as have many LaTeX packages, web content
</p>

View File

@@ -1,11 +1,69 @@
<script lang="ts"> <script lang="ts">
import CharaPick from "$lib/CharaPick.svelte"; import { charaNames } from '$lib/data/character';
import { byChara, locations, groupPopulars } from '$lib/data/convo';
import CharaPick from '$lib/CharaPick.svelte';
let charaID = $state(1001) const minSuggest = 8;
let convo = $state(1)
let charaID = $state(1001);
let convo = $state(1);
let options = $derived(byChara.global.get(charaID) ?? []);
let cur = $derived(options.find((c) => c.number === convo));
let suggested = $derived.by(() => {
if (cur == null) {
return [];
}
const u = groupPopulars.global[locations[cur.location].group].filter(
(s) => charaNames.get(s[0]) != null && s[0] !== cur.chara_1 && s[0] !== cur.chara_2 && s[0] !== cur.chara_3,
);
const r = u.length <= minSuggest ? u : u.filter((s) => s[1] >= u[minSuggest][1]);
return r.map(([chara_id, count]) => ({ chara_id, count }));
});
</script> </script>
<h1>Lobby Conversations</h1> <h1 class="text-4xl">Lobby Conversations</h1>
<p>Find which horses are in a given lobby conversation, and get recommendations on which ones to assign to fixed locations to maximize the chance of getting it.</p> <div class="mx-auto mt-8 flex flex-col rounded-md text-center shadow-md ring md:max-w-xl md:flex-row">
<hr> <div class="m-4 flex-1 md:mt-3">
<CharaPick id="chara" label="Select a character" bind:value={charaID} required /> <CharaPick id="chara" class="w-full" label="Character" labelClass="hidden md:inline" bind:value={charaID} required />
</div>
<div class="m-4 flex-1 md:mt-3">
<label for="convo" class="hidden md:inline">Conversation</label>
<select id="convo" bind:value={convo} class="w-full">
{#each options as opt}
<option value={opt.number}>Slice of Life {opt.number}</option>
{/each}
</select>
</div>
</div>
{#if cur}
<div class="shadow-sm transition-shadow hover:shadow-md">
<div class="mt-8 flex text-center text-lg">
<span class="flex-1"
>{charaNames.get(cur.chara_1)?.en ?? 'someone not a trainee'}{(cur.chara_2 ?? cur.chara_3) == null ? ' alone' : ''}</span
>
{#if cur.chara_2}
<span class="flex-1">{charaNames.get(cur.chara_2)?.en ?? 'someone not a trainee'}</span>
{/if}
{#if cur.chara_3}
<span class="flex-1">{charaNames.get(cur.chara_3)?.en ?? 'someone not a trainee'}</span>
{/if}
</div>
<div class="flex w-full text-center text-lg">
<span class="flex-1">at {locations[cur.location].name.en}</span>
</div>
</div>
<div class="mt-4 block text-center">
<span>Other characters who appear here most often:</span>
</div>
<div class="mt-4 grid text-center shadow-sm transition-shadow ease-in hover:shadow-md hover:ease-out md:grid-cols-4">
{#each suggested as s}
<span>{charaNames.get(s.chara_id)?.en}: {s.count}&#xd7;</span>
{/each}
</div>
<div class="mt-4 block text-center">
<span>
Set these characters to fixed positions (main, upgrades, story, races) to maximize the chance of getting this conversation.
</span>
</div>
{/if}

View File

@@ -1,6 +1,42 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import './sakura-vars.css';
@theme { :root {
--text-sm: 1.25rem; color-scheme: light dark;
}
html,
body {
height: 100%;
padding: 0;
margin: 0;
}
html {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
}
body {
background-color: light-dark(var(--color-mist-200), var(--color-mist-800));
color: light-dark(var(--color-amber-950), var(--color-amber-50));
}
p {
margin-bottom: calc(var(--spacing) * 4);
}
a {
color: light-dark(var(--color-sky-900), var(--color-sky-100));
}
nav > span > a {
color: light-dark(var(--color-amber-950), var(--color-amber-50));
}
a:hover {
border-bottom-width: 1px;
}
select {
background-color: light-dark(var(--color-mist-300), var(--color-mist-900));
padding: 0.5rem 0.75rem;
} }

View File

@@ -1,401 +0,0 @@
/* Sakura.css v1.5.0
* ================
* Minimal css theme.
* Project: https://github.com/oxalorg/sakura/
*/
/* data-theme="taiyō" */
:root {
--blossom: #292722;
--fade: #7d7768;
--bg: #ffecec;
--bg-alt: #ffecec;
--text: #292222;
}
[data-theme="iron goddess"] {
--blossom: #424b51;
--fade: #64707a;
--bg: #fff2e2;
--bg-alt: #fffce2;
--text: #2c2923;
}
[data-theme="main sequence"] {
--blossom: #3a5425;
--fade: #698650;
--bg: #fffde5;
--bg-alt: #fff4e5;
--text: #5e592a;
}
[data-theme="sorcery"] {
--blossom: #5a5a69;
--fade: #868698;
--bg: #e5f4e5;
--bg-alt: #e6f4e6;
--text: #323932;
}
[data-theme="cirrus"] {
--blossom: #565a4b;
--fade: #9da587;
--bg: #e5f6fa;
--bg-alt: #e5f6fa;
--text: #31393b;
}
[data-theme="oxygen"] {
--blossom: #162011;
--fade: #343932;
--bg: #e1e2e4;
--bg-alt: #e3e0e3;
--text: #27282c;
}
[data-theme="dauphin"] {
--blossom: #171e1c;
--fade: #485b58;
--bg: #ebe5f8;
--bg-alt: #ebe5f8;
--text: #1c1a20;
}
[data-theme="diamond-burned"] {
--blossom: #0f0d0b;
--fade: #4d4743;
--bg: #f8ebf2;
--bg-alt: #ebe8f4;
--text: #3e363a;
}
[data-theme="chi"] {
--blossom: #908975;
--fade: #fff8e5;
--bg: #110c0c;
--bg-alt: #0a090c;
--text: #cfa9a9;
}
[data-theme="darjeeling"] {
--blossom: #ba949c;
--fade: #f8e1e6;
--bg: #1c160d;
--bg-alt: #1c160d;
--text: #c9b9a0;
}
[data-theme="subgiant"] {
--blossom: #9fad8a;
--fade: #e8f2d7;
--bg: #16130b;
--bg-alt: #16130b;
--text: #bbb396;
}
[data-theme="goblin"] {
--blossom: #7a808e;
--fade: #dae1ef;
--bg: #070905;
--bg-alt: #0a0906;
--text: #acbd9f;
}
[data-theme="altostratus"] {
--blossom: #a8a0b7;
--fade: #e5dbf7;
--bg: #0c0f0f;
--bg-alt: #1a1614;
--text: #8da4a4;
}
[data-theme="silicon"] {
--blossom: #717f63;
--fade: #c4d4b3;
--bg: #050a0f;
--bg-alt: #050a0f;
--text: #838e9a;
}
[data-theme="imperator"] {
--blossom: #93a0a3;
--fade: #f3fbfd;
--bg: #0e0c12;
--bg-alt: #0e0c12;
--text: #a8a1b1;
}
[data-theme="mædi"] {
--blossom: #ccd3b6;
--fade: #fdfbf3;
--bg: #10090f;
--bg-alt: #2f282e;
--text: #9e889a;
}
/* Body */
html {
font-size: 62.5%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
}
body {
font-size: 1.8rem;
line-height: 1.618;
/* max-width: 38em; */
margin: auto;
color: var(--text);
background-color: var(--bg);
padding: 13px;
}
@media (max-width: 684px) {
body {
font-size: 1.53rem;
}
}
@media (max-width: 382px) {
body {
font-size: 1.35rem;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
font-weight: 700;
margin-top: 3rem;
margin-bottom: 1.5rem;
overflow-wrap: break-word;
word-wrap: break-word;
-ms-word-break: break-all;
word-break: break-word;
}
h1 {
font-size: 2.35em;
}
h2 {
font-size: 2em;
}
h3 {
font-size: 1.75em;
}
h4 {
font-size: 1.5em;
}
h5 {
font-size: 1.25em;
}
h6 {
font-size: 1em;
}
p {
margin-top: 0px;
margin-bottom: 2.5rem;
}
small,
sub,
sup {
font-size: 75%;
}
hr {
border-color: var(--blossom);
}
a {
text-decoration: none;
color: var(--blossom);
}
a:hover {
color: var(--fade);
border-bottom: 2px solid var(--text);
}
ul {
padding-left: 1.4em;
margin-top: 0px;
margin-bottom: 2.5rem;
}
li {
margin-bottom: 0.4em;
}
blockquote {
margin-left: 0px;
margin-right: 0px;
padding-left: 1em;
padding-top: 0.8em;
padding-bottom: 0.8em;
padding-right: 0.8em;
border-left: 5px solid var(--blossom);
margin-bottom: 2.5rem;
background-color: var(--bg-alt);
}
blockquote p {
margin-bottom: 0;
}
img,
video {
height: auto;
max-width: 100%;
margin-top: 0px;
margin-bottom: 2.5rem;
}
/* Pre and Code */
pre {
background-color: var(--bg-alt);
display: block;
padding: 1em;
overflow-x: auto;
margin-top: 0px;
margin-bottom: 2.5rem;
font-size: 0.9em;
}
code,
kbd,
samp {
font-size: 0.9em;
padding: 0 0.5em;
background-color: var(--bg-alt);
white-space: pre-wrap;
}
pre>code {
padding: 0;
background-color: transparent;
white-space: pre;
font-size: 1em;
}
/* Tables */
table {
text-align: justify;
width: 100%;
border-collapse: collapse;
margin-bottom: 2rem;
}
td,
th {
padding: 0.5em;
border-bottom: 1px solid var(--bg-alt);
}
/* Buttons, forms and input */
input,
textarea {
border: 1px solid var(--text);
}
input:focus,
textarea:focus {
border: 1px solid var(--blossom);
}
textarea {
width: 100%;
}
.button,
button,
input[type=submit],
input[type=reset],
input[type=button],
input[type=file]::file-selector-button {
display: inline-block;
padding: 5px 10px;
text-align: center;
text-decoration: none;
white-space: nowrap;
background-color: var(--blossom);
color: var(--bg);
border-radius: 1px;
border: 1px solid var(--blossom);
cursor: pointer;
box-sizing: border-box;
}
.button[disabled],
button[disabled],
input[type=submit][disabled],
input[type=reset][disabled],
input[type=button][disabled],
input[type=file]::file-selector-button[disabled] {
cursor: default;
opacity: 0.5;
}
.button:hover,
button:hover,
input[type=submit]:hover,
input[type=reset]:hover,
input[type=button]:hover,
input[type=file]::file-selector-button:hover {
background-color: var(--fade);
color: var(--bg);
outline: 0;
}
.button:focus-visible,
button:focus-visible,
input[type=submit]:focus-visible,
input[type=reset]:focus-visible,
input[type=button]:focus-visible,
input[type=file]::file-selector-button:focus-visible {
outline-style: solid;
outline-width: 2px;
}
textarea,
select,
input {
color: var(--text);
padding: 6px 10px;
/* The 6px vertically centers text on FF, ignored by Webkit */
margin-bottom: 10px;
background-color: var(--bg-alt);
border: 1px solid var(--bg-alt);
border-radius: 4px;
box-shadow: none;
box-sizing: border-box;
}
textarea:focus,
select:focus,
input:focus {
border: 1px solid var(--blossom);
outline: 0;
}
input[type=checkbox]:focus {
outline: 1px dotted var(--blossom);
}
label,
legend,
fieldset {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}

View File

@@ -1,3 +1,6 @@
<h1>Spark Generation Chance</h1> <h1>Spark Generation Chance</h1>
<p>Given a legacy, calculate the chance of generating each spark if you fulfill the conditions to do so, and the distribution of total spark counts.</p> <p>
Given a legacy, calculate the chance of generating each spark if you fulfill the conditions to do so, and the distribution of
total spark counts.
</p>
<p>TODO</p> <p>TODO</p>

View File

@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto'; import adapter from '@sveltejs/adapter-static';
import { relative, sep } from 'node:path'; import { relative, sep } from 'node:path';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
@@ -11,14 +11,11 @@ const config = {
const isExternalLibrary = pathSegments.includes('node_modules'); const isExternalLibrary = pathSegments.includes('node_modules');
return isExternalLibrary ? undefined : true; return isExternalLibrary ? undefined : true;
} },
}, },
kit: { kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. adapter: adapter(),
// If your environment is not supported, or you settled on a specific environment, switch out the adapter. },
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
}; };
export default config; export default config;

View File

@@ -15,11 +15,11 @@ export default defineConfig({
browser: { browser: {
enabled: true, enabled: true,
provider: playwright(), provider: playwright(),
instances: [{ browser: 'chromium', headless: true }] instances: [{ browser: 'chromium', headless: true }],
}, },
include: ['src/**/*.svelte.{test,spec}.{js,ts}'], include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**'] exclude: ['src/lib/server/**'],
} },
}, },
{ {
@@ -28,9 +28,9 @@ export default defineConfig({
name: 'server', name: 'server',
environment: 'node', environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'], include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
} },
} },
] ],
} },
}); });