25 Commits

Author SHA1 Message Date
Azalea
385bbd115d [F] Fix rank 2025-01-03 10:16:21 -05:00
Azalea
6a45df683b [+] Maimai2 unique constraints 2025-01-03 03:44:45 -05:00
Azalea
21e023e609 Revert "[U] Upgrade dependencies"
This reverts commit 332eacd2cc.
2025-01-03 03:09:53 -05:00
Azalea
f6489d5ac0 [+] Add token to logging 2025-01-03 02:19:11 -05:00
Azalea
256aac8faf [+] Add error logging 2025-01-03 01:45:42 -05:00
Azalea
a1be699ec5 [+] characterId 2025-01-02 10:51:45 -05:00
Azalea
d71af941b0 [+] Add some get-only fields 2025-01-02 10:44:06 -05:00
Azalea
a1b56f6e0b [O] Do not log GetUserPortraitApi 2025-01-02 07:13:06 -05:00
Azalea
d8022cc1a4 [F] Fix userbox file logic #97 2025-01-02 07:12:46 -05:00
Azalea
9ba7f5022e [+] Add userbox.new chinese i18n #97 2025-01-01 19:05:45 -05:00
Azalea
437ed2ee60 [+] Chuni Userbox with Assets (#97) 2025-01-01 18:59:55 -05:00
Raymond
4d4335004f refactor: move DDS cache
moved the DDS cache from dds.ts to ddsCache.ts and added caching for scaled images

Co-authored-by: split / May <split@split.pet>
2025-01-01 16:30:48 -05:00
Raymond
ce95f2165d style: make nameplates fit better 2025-01-01 15:06:52 -05:00
split / May
931e611cf7 revert: Reverse decision to remove the classic UserBox preview
Adds back the classic UserBox preview when AquaBox is disabled / unavailable
2025-01-01 06:21:12 -08:00
split / May
81ef029bf6 docs: 📝 add TSDoc comments to functions in the DDS class 2025-01-01 05:21:29 -08:00
split / May
223de57b65 style: linebreaks 2025-01-01 05:13:13 -08:00
split / May
f1d1b81456 refactor: ♻️ replace "CHUNITHM" string with "AquaDX"
recommendation from @raymonable
2025-01-01 04:23:04 -08:00
Raymond
8aa829ab02 [+] Chuni Userbox with Assets
Co-authored-by: split / May  <split@split.pet>
2025-01-01 06:27:38 -05:00
Azalea
8fb443d41d Update docker-image.yml 2024-12-31 05:25:01 +08:00
Azalea
edc62b3cfc Update docker-image.yml 2024-12-31 04:27:25 +08:00
Azalea
644cdef95f Update docker-image.yml 2024-12-31 04:19:50 +08:00
Azalea
dc54473669 [U] Upgrade gradle 2024-12-29 20:31:16 -05:00
Azalea
332eacd2cc [U] Upgrade dependencies 2024-12-29 20:23:21 -05:00
Azalea
c26a670b05 [U] Upgrade kt only 2024-12-29 09:36:30 -05:00
Azalea
1ccb8694d8 Revert "[U] Upgrade deps"
This reverts commit d3c25e6b12.
2024-12-29 09:24:15 -05:00
22 changed files with 1398 additions and 66 deletions

View File

@@ -56,7 +56,6 @@ jobs:
push: true
tags: |
hykilpikonna/aquadx:latest
ghcr.io/${{ github.repository_owner }}/AquaDX:latest
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache

View File

@@ -16,6 +16,14 @@
import { filter } from "d3";
import { coverNotFound } from "../../libs/ui";
import { userboxFileProcess, ddsDB, initializeDb } from "../../libs/userbox/userbox"
import ChuniPenguinComponent from "./userbox/ChuniPenguin.svelte"
import ChuniUserplateComponent from "./userbox/ChuniUserplate.svelte";
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
import { DDS } from "../../libs/userbox/dds";
let user: AquaNetUser
let [loading, error, submitting, preview] = [true, "", "", ""]
let changed: string[] = [];
@@ -26,7 +34,7 @@
let iKinds = { namePlate: 1, frame: 2, trophy: 3, mapIcon: 8, systemVoice: 9, avatarAccessory: 11 }
// In userbox: 'nameplateId', 'frameId', 'trophyId', 'mapIconId', 'voiceId', 'avatar{Wear/Head/Face/Skin/Item/Front/Back}'
let userbox: UserBox
let avatarKinds = ['Wear', 'Head', 'Face', 'Skin', 'Item', 'Front', 'Back']
let avatarKinds = ['Wear', 'Head', 'Face', 'Skin', 'Item', 'Front', 'Back'] as const
// iKey should match allItems keys, and ubKey should match userbox keys
let userItems: { iKey: string, ubKey: keyof UserBox, items: UserItem[] }[] = []
@@ -83,6 +91,40 @@
user = u
return fetchData()
}).catch((e) => { loading = false; error = e.message });
let DDSreader: DDS | undefined;
let USERBOX_PROGRESS = 0;
let USERBOX_SETUP_RUN = false;
let USERBOX_SETUP_TEXT = t("userbox.new.setup");
let USERBOX_ENABLED = useLocalStorage("userboxNew", false);
let USERBOX_INSTALLED = false;
let USERBOX_SUPPORT = "webkitGetAsEntry" in DataTransferItem.prototype;
type OnlyNumberPropsOf<T extends Record<string, any>> = {[Prop in keyof T as (T[Prop] extends number ? Prop : never)]: T[Prop]}
let userboxSelected: keyof OnlyNumberPropsOf<UserBox> = "avatarWear";
const userboxNewOptions = ["systemVoice", "frame", "trophy", "mapIcon"]
async function userboxSafeDrop(event: Event & { currentTarget: EventTarget & HTMLInputElement; }) {
if (!event.target) return null;
let input = event.target as HTMLInputElement;
let folder = input.webkitEntries[0];
error = await userboxFileProcess(folder, (progress: number, progressString: string) => {
USERBOX_SETUP_TEXT = progressString;
USERBOX_PROGRESS = progress;
}) ?? "";
}
indexedDB.databases().then(async (dbi) => {
let databaseExists = dbi.some(db => db.name == "userboxChusanDDS");
if (databaseExists) {
await initializeDb();
DDSreader = new DDS(ddsDB);
USERBOX_INSTALLED = databaseExists;
}
})
</script>
<StatusOverlays {error} loading={loading || !!submitting} />
@@ -91,42 +133,140 @@
<h2>{t("userbox.header.general")}</h2>
<GameSettingFields game="chu3"/>
<h2>{t("userbox.header.userbox")}</h2>
<div class="fields">
{#each userItems as { iKey, ubKey, items }, i}
<div class="field">
<label for={ubKey}>{ts(`userbox.${ubKey}`)}</label>
<div>
<select bind:value={userbox[ubKey]} id={ubKey} on:change={() => changed = [...changed, ubKey]}>
{#each items as option}
<option value={option.itemId}>{allItems[iKey][option.itemId]?.name || `(unknown ${option.itemId})`}</option>
{/each}
</select>
{#if changed.includes(ubKey)}
<button transition:slide={{axis: "x"}} on:click={() => submit(ubKey)} disabled={!!submitting}>
{t("settings.profile.save")}
</button>
{/if}
</div>
</div>
{/each}
</div>
{#if HAS_USERBOX_ASSETS}
<h2>{t("userbox.header.preview")}</h2>
<p class="notice">{t("userbox.preview.notice")}</p>
<input bind:value={preview} placeholder={t("userbox.preview.url")}/>
{#if preview}
<div class="preview">
{#each userItems.filter(v => v.iKey != 'trophy' && v.iKey != 'systemVoice') as { iKey, ubKey, items }, i}
{#if !USERBOX_ENABLED.value || !USERBOX_INSTALLED}
<div class="fields">
{#each userItems as { iKey, ubKey, items }, i}
<div class="field">
<label for={ubKey}>{ts(`userbox.${ubKey}`)}</label>
<div>
<span>{ts(`userbox.${ubKey}`)}</span>
<img src={`${preview}/${iKey}/${userbox[ubKey].toString().padStart(8, '0')}.png`} alt="" on:error={coverNotFound} />
<select bind:value={userbox[ubKey]} id={ubKey} on:change={() => changed = [...changed, ubKey]}>
{#each items as option}
<option value={option.itemId}>{allItems[iKey][option.itemId]?.name || `(unknown ${option.itemId})`}</option>
{/each}
</select>
{#if changed.includes(ubKey)}
<button transition:slide={{axis: "x"}} on:click={() => submit(ubKey)} disabled={!!submitting}>
{t("settings.profile.save")}
</button>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<div class="chuni-userbox-container">
<ChuniUserplateComponent on:click={() => userboxSelected = "nameplateId"} chuniCharacter={userbox.characterId} chuniLevel={userbox.level} chuniRating={userbox.playerRating / 100}
chuniNameplate={userbox.nameplateId} chuniName={userbox.userName} chuniTrophyName={allItems.trophy[userbox.trophyId].name}></ChuniUserplateComponent>
<ChuniPenguinComponent classPassthrough="chuni-penguin-float" chuniWear={userbox.avatarWear} chuniHead={userbox.avatarHead} chuniBack={userbox.avatarBack}
chuniFront={userbox.avatarFront} chuniFace={userbox.avatarFace} chuniItem={userbox.avatarItem}
chuniSkin={userbox.avatarSkin}></ChuniPenguinComponent>
</div>
<div class="chuni-userbox-row">
{#each avatarKinds as avatarKind}
{#await DDSreader?.getFile(`avatarAccessoryThumbnail:${userbox[`avatar${avatarKind}`].toString().padStart(8, "0")}`) then imageURL}
<button on:click={() => userboxSelected = `avatar${avatarKind}`}>
<img src={imageURL} class={userboxSelected == `avatar${avatarKind}` ? "focused" : ""} alt={allItems.avatarAccessory[userbox[`avatar${avatarKind}`]].name} title={allItems.avatarAccessory[userbox[`avatar${avatarKind}`]].name}>
</button>
{/await}
{/each}
</div>
<div class="chuni-userbox">
{#if userboxSelected == "nameplateId"}
{#each userItems.find(f => f.ubKey == "nameplateId")?.items ?? [] as item}
{#await DDSreader?.getFile(`nameplate:${item.itemId.toString().padStart(8, "0")}`) then imageURL}
<button class="nameplate" on:click={() => {userbox[userboxSelected] = item.itemId; submit(userboxSelected)}}>
<img src={imageURL} alt={allItems.namePlate[item.itemId].name} title={allItems.namePlate[item.itemId].name}>
</button>
{/await}
{/each}
{:else}
{#each userItems.find(f => f.ubKey == userboxSelected)?.items ?? [] as item}
{#await DDSreader?.getFile(`avatarAccessoryThumbnail:${item.itemId.toString().padStart(8, "0")}`) then imageURL}
<button on:click={() => {userbox[userboxSelected] = item.itemId; submit(userboxSelected)}}>
<img src={imageURL} alt={allItems.avatarAccessory[item.itemId].name} title={allItems.avatarAccessory[item.itemId].name}>
</button>
{/await}
{/each}
{/if}
</div>
<div class="fields">
{#each userItems.filter(i => userboxNewOptions.includes(i.iKey)) as { iKey, ubKey, items }, i}
<div class="field">
<label for={ubKey}>{ts(`userbox.${ubKey}`)}</label>
<div>
<select bind:value={userbox[ubKey]} id={ubKey} on:change={() => changed = [...changed, ubKey]}>
{#each items as option}
<option value={option.itemId}>{allItems[iKey][option.itemId]?.name || `(unknown ${option.itemId})`}</option>
{/each}
</select>
{#if changed.includes(ubKey)}
<button transition:slide={{axis: "x"}} on:click={() => submit(ubKey)} disabled={!!submitting}>
{t("settings.profile.save")}
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{#if HAS_USERBOX_ASSETS}
{#if USERBOX_INSTALLED}
<!-- god this is a mess but idgaf atp -->
<div class="field boolean" style:margin-top="1em">
<input type="checkbox" bind:checked={USERBOX_ENABLED.value} id="newUserbox">
<label for="newUserbox">
<span class="name">{t("userbox.new.activate")}</span>
<span class="desc">{t(`userbox.new.activate_desc`)}</span>
</label>
</div>
{/if}
{#if USERBOX_SUPPORT}
<p>
<button on:click={() => USERBOX_SETUP_RUN = !USERBOX_SETUP_RUN}>{t(!USERBOX_INSTALLED ? `userbox.new.activate_first` : `userbox.new.activate_update`)}</button>
</p>
{/if}
{#if !USERBOX_SUPPORT || !USERBOX_INSTALLED || !USERBOX_ENABLED.value}
<h2>{t("userbox.header.preview")}</h2>
<p class="notice">{t("userbox.preview.notice")}</p>
<input bind:value={preview} placeholder={t("userbox.preview.url")}/>
{#if preview}
<div class="preview">
{#each userItems.filter(v => v.iKey != 'trophy' && v.iKey != 'systemVoice') as { iKey, ubKey, items }, i}
<div>
<span>{ts(`userbox.${ubKey}`)}</span>
<img src={`${preview}/${iKey}/${userbox[ubKey].toString().padStart(8, '0')}.png`} alt="" on:error={coverNotFound} />
</div>
{/each}
</div>
{/if}
{/if}
{/if}
</div>
{/if}
{#if USERBOX_SETUP_RUN && !error}
<div class="overlay" transition:fade>
<div>
<h2>{t('userbox.new.name')}</h2>
<span>{USERBOX_SETUP_TEXT}</span>
<div class="actions">
{#if USERBOX_PROGRESS != 0}
<div class="progress">
<div class="progress-bar" style="width: {USERBOX_PROGRESS}%"></div>
</div>
{:else}
<button class="drop-btn">
<input type="file" on:input={userboxSafeDrop} on:click={e => e.preventDefault()}>
{t('userbox.new.drop')}
</button>
<button on:click={() => USERBOX_SETUP_RUN = false}>
{t('back')}
</button>
{/if}
</div>
</div>
</div>
{/if}
<style lang="sass">
@use "../../vars"
@@ -134,6 +274,7 @@
input
width: 100%
h2
margin-bottom: 0.5rem
@@ -141,6 +282,36 @@ p.notice
opacity: 0.6
margin-top: 0
.progress
width: 100%
height: 10px
box-shadow: 0 0 1px 1px vars.$ov-lighter
border-radius: 25px
margin-bottom: 15px
overflow: hidden
.progress-bar
background: #b3c6ff
height: 100%
border-radius: 25px
.drop-btn
position: relative
width: 100%
aspect-ratio: 3
background: transparent
box-shadow: 0 0 1px 1px vars.$ov-lighter
margin-bottom: 1em
> input
position: absolute
top: 0
left: 0
width: 100%
height: 100%
opacity: 0
.preview
margin-top: 32px
display: flex
@@ -202,4 +373,84 @@ p.notice
> select
flex: 1
.field.boolean
display: flex
flex-direction: row
align-items: center
gap: 1rem
width: auto
input
width: auto
aspect-ratio: 1 / 1
label
display: flex
flex-direction: column
max-width: max-content
.desc
opacity: 0.6
/* AquaBox */
.chuni-userbox-row
width: 100%
display: flex
button
padding: 0
margin: 0
width: 100%
flex: 0 1 100%
background: none
aspect-ratio: 1
img
width: 100%
filter: brightness(50%)
&.focused
filter: brightness(75%)
.chuni-userbox
width: calc(100% - 20px)
height: 350px
display: flex
flex-direction: row
flex-wrap: wrap
padding: 10px
background: vars.$c-bg
border-radius: 16px
overflow-y: auto
margin-bottom: 15px
justify-content: center
button
padding: 0
margin: 0
width: 20%
align-self: flex-start
background: none
aspect-ratio: 1
img
width: 100%
&.nameplate
width: 50%
aspect-ratio: unset
border: none
.chuni-userbox-container
display: flex
align-items: center
justify-content: center
@media (max-width: 1000px)
.chuni-userbox-container
flex-wrap: wrap
</style>

View File

@@ -0,0 +1,165 @@
<script lang="ts">
import { DDS } from "../../../libs/userbox/dds"
import { ddsDB } from "../../../libs/userbox/userbox"
const DDSreader = new DDS(ddsDB);
export var chuniWear = 1100001;
export var chuniHead = 1200001;
export var chuniFace = 1300001;
export var chuniSkin = 1400001;
export var chuniItem = 1500001;
export var chuniFront = 1600001;
export var chuniBack = 1700001;
export var classPassthrough: string = ``
</script>
<div class="chuni-penguin {classPassthrough}">
<div class="chuni-penguin-body">
<!-- Body -->
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniSkin.toString().padStart(8, "0")}`, 0, 0, 256, 400, 0.75) then imageURL}
<img class="chuni-penguin-skin" src={imageURL} alt="Body">
{/await}
<!-- Face -->
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_face_00.dds", 0, 0, 225, 150, 0.75) then imageURL}
<img class="chuni-penguin-eyes chuni-penguin-accessory" src={imageURL} alt="Eyes">
{/await}
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 86, 103, 96, 43, 0.75) then imageURL}
<img class="chuni-penguin-beak chuni-penguin-accessory" src={imageURL} alt="Beak">
{/await}
<!-- Arms (surfboard) -->
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 0, 0, 85, 160, 0.75) then imageURL}
<img class="chuni-penguin-arm-left chuni-penguin-arm" src={imageURL} alt="Left Arm">
{/await}
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 0, 0, 85, 160, 0.75) then imageURL}
<img class="chuni-penguin-arm-right chuni-penguin-arm" src={imageURL} alt="Right Arm">
{/await}
<!-- Wear -->
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniWear.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01100001`) then imageURL}
<img class="chuni-penguin-wear chuni-penguin-accessory" src={imageURL} alt="Wear">
{/await}
<!-- Head -->
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniHead.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01200001`) then imageURL}
<img class="chuni-penguin-head chuni-penguin-accessory" src={imageURL} alt="Head">
{/await}
{#if chuniHead == 1200001}
<!-- If wearing original hat, add the feather and attachment -->
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 104, 153, 57, 58, 0.75) then imageURL}
<img class="chuni-penguin-head-2 chuni-penguin-accessory" src={imageURL} alt="Head2">
{/await}
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 5, 160, 100, 150, 0.75) then imageURL}
<img class="chuni-penguin-head-3 chuni-penguin-accessory" src={imageURL} alt="Head3">
{/await}
{/if}
<!-- Face (Accessory) -->
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniFace.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01300001`) then imageURL}
<img class="chuni-penguin-face-accessory chuni-penguin-accessory" src={imageURL} alt="Face (Accessory)">
{/await}
<!-- Item -->
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniItem.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01500001`) then imageURL}
<img class="chuni-penguin-item chuni-penguin-accessory" src={imageURL} alt="Item">
{/await}
<!-- Front -->
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniFront.toString().padStart(8, "0")}`, 0.75) then imageURL}
<img class="chuni-penguin-front chuni-penguin-accessory" src={imageURL} alt="Front">
{/await}
<!-- Back -->
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniBack.toString().padStart(8, "0")}`, 0.75) then imageURL}
<img class="chuni-penguin-back chuni-penguin-accessory" src={imageURL} alt="Back">
{/await}
</div>
<div class="chuni-penguin-feet">
<!-- Feet -->
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniSkin.toString().padStart(8, "0")}`, 0, 410, 167, 80, 0.75) then imageURL}
<img src={imageURL} alt="Feet">
{/await}
</div>
</div>
<!-- Truly sorry for the horrors below -->
<style lang="sass">
@keyframes chuniPenguinBodyBob
0%
transform: translate(-50%, 0%) translate(0%, -50%)
50%
transform: translate(-50%, 10px) translate(0%, -50%)
100%
transform: translate(-50%, 0%) translate(0%, -50%)
@keyframes chuniPenguinArmLeft
0%
transform: translate(-50%, 0) rotate(-2deg)
50%
transform: translate(-50%, 0) rotate(2deg)
100%
transform: translate(-50%, 0) rotate(-2deg)
@keyframes chuniPenguinArmRight
0%
transform: translate(-50%, 0) scaleX(-1) rotate(-2deg)
50%
transform: translate(-50%, 0) scaleX(-1) rotate(2deg)
100%
transform: translate(-50%, 0) scaleX(-1) rotate(-2deg)
img
-webkit-user-drag: none
.chuni-penguin
height: 512px
aspect-ratio: 1/2
position: relative
.chuni-penguin-body, .chuni-penguin-feet
transform: translate(-50%, -50%)
position: absolute
left: 50%
.chuni-penguin-body
top: 50%
z-index: 1
animation: chuniPenguinBodyBob 2s infinite cubic-bezier(0.45, 0, 0.55, 1)
.chuni-penguin-feet
top: 82.5%
z-index: 0
.chuni-penguin-arm
transform-origin: 95% 10%
position: absolute
top: 40%
.chuni-penguin-arm-left
left: 0%
animation: chuniPenguinArmLeft 1.5s infinite cubic-bezier(0.45, 0, 0.55, 1)
.chuni-penguin-arm-right
left: 70%
animation: chuniPenguinArmRight 1.5s infinite cubic-bezier(0.45, 0, 0.55, 1)
.chuni-penguin-accessory
transform: translate(-50%, -50%)
position: absolute
top: 50%
left: 50%
.chuni-penguin-eyes
top: 22.5%
.chuni-penguin-beak
top: 29.5%
.chuni-penguin-wear
top: 57.5%
.chuni-penguin-head
top: 7.5%
z-index: 10
.chuni-penguin-head-2
top: 12.5%
.chuni-penguin-head-3
top: -12.5%
.chuni-penguin-face-accessory
top: 27.5%
.chuni-penguin-back
z-index: -1
</style>

View File

@@ -0,0 +1,137 @@
<script lang="ts">
import { DDS } from "../../../libs/userbox/dds"
import { ddsDB } from "../../../libs/userbox/userbox"
const DDSreader = new DDS(ddsDB);
export var chuniLevel: number = 1
export var chuniName: string = "AquaDX"
export var chuniRating: number = 1.23
export var chuniNameplate: number = 1
export var chuniCharacter: number = 0
export var chuniTrophyName: string = "NEWCOMER"
</script>
{#await DDSreader?.getFile(`nameplate:${chuniNameplate.toString().padStart(8, "0")}`) then nameplateURL}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div on:click class="chuni-nameplate" style:background={`url(${nameplateURL})`}>
{#await DDSreader?.getFile(`characterThumbnail:${chuniCharacter.toString().padStart(6, "0")}`) then characterThumbnailURL}
<img class="chuni-character" src={characterThumbnailURL} alt="Character">
{/await}
{#await DDSreader?.getFileFromSheet("surfboard:CHU_UI_title_rank_00_v10.dds", 5, 5 + (75 * 2), 595, 64) then trophyURL}
<div class="chuni-trophy">
{chuniTrophyName}
<img src={trophyURL} class="chuni-trophy-bg" alt="Trophy">
</div>
{/await}
<div class="chuni-user-info">
<div class="chuni-user-name">
<span>
Lv.
<span class="chuni-user-level">
{chuniLevel}
</span>
</span>
<span class="chuni-user-name-text">
{chuniName}
</span>
</div>
<div class="chuni-user-rating">
RATING
<span class="chuni-user-rating-number">
{chuniRating}
</span>
</div>
</div>
</div>
{/await}
<style lang="sass">
@use "../../../vars"
.chuni-nameplate
width: 576px
height: 228px
position: relative
font-size: 16px
/* Overlap penguin avatar when put side to side */
z-index: 2
cursor: pointer
.chuni-trophy
width: 410px
height: 45px
background-position: center
background-size: cover
color: black
display: flex
justify-content: center
align-items: center
position: absolute
right: 25px
top: 40px
font-size: 1.15em
font-family: sans-serif
font-weight: bold
z-index: 1
text-shadow: 0 1px white
img
width: 100%
height: 100%
position: absolute
z-index: -1
.chuni-character
position: absolute
top: 87px
right: 25px
width: 82px
aspect-ratio: 1
box-shadow: 0 0 1px 1px white
background: #efefef
.chuni-user-info
height: 82px
width: 320px
position: absolute
top: 87px
right: 110px
background: #fff9
border-radius: 1px
box-shadow: 0 0 1px 1px #ccc
display: flex
flex-direction: column
.chuni-user-name, .chuni-user-rating
margin: 0 4px
display: flex
align-items: center
color: black
font-family: sans-serif
font-weight: bold
.chuni-user-name
flex: 1 0 65%
box-shadow: 0 1px 0 #ccc
.chuni-user-level
font-size: 2em
margin-left: 10px
.chuni-user-name-text
margin-left: auto
font-size: 2em
.chuni-user-rating
flex: 1 0 35%
font-size: 0.875em
text-shadow: #333 1px 1px, #333 1px -1px, #333 -1px 1px, #333 -1px -1px
color: #ddf
.chuni-user-rating-number
font-size: 1.5em
margin-left: 10px
</style>

View File

@@ -149,4 +149,7 @@ export interface UserBox {
avatarItem: number,
avatarFront: number,
avatarBack: number,
level: number
playerRating: number
}

View File

@@ -179,6 +179,17 @@ export const EN_REF_USERBOX = {
'userbox.preview.notice': 'To honor the copyright, we cannot host the images of the userbox items. However, if someone else is willing to provide the images, you can enter their URL here and it will be displayed.',
'userbox.preview.url': 'Image URL',
'userbox.error.nodata': 'Chuni data not found',
'userbox.new.name': 'AquaBox',
'userbox.new.setup': 'Drag and drop your Chuni game folder (Lumi or newer) into the box below to display UserBoxes with their nameplate & avatar. All files are handled in-browser.',
'userbox.new.setup.processing_file': 'Processing',
'userbox.new.setup.finalizing': 'Saving to internal storage',
'userbox.new.drop': 'Drop game folder here',
'userbox.new.activate_first': 'Enable AquaBox (game files required)',
'userbox.new.activate_update': 'Update AquaBox (game files required)',
'userbox.new.activate': 'Use AquaBox',
'userbox.new.activate_desc': 'Enable displaying UserBoxes with their nameplate & avatar',
'userbox.new.error.invalidFolder': 'The folder you selected is invalid. Ensure that your game\'s version is Lumi or newer and that the "A001" option pack is present.'
}
export const EN_REF = { ...EN_REF_USER, ...EN_REF_Welcome, ...EN_REF_GENERAL,

View File

@@ -189,6 +189,17 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
'userbox.preview.notice': '「生存战略」:为了尊重版权,我们不会提供游戏内物品的图片。但是如果你认识其他愿意提供图床的人,在这里输入 URL 就可以显示出预览。',
'userbox.preview.url': '图床 URL',
'userbox.error.nodata': '未找到中二数据',
'userbox.new.name': 'AquaBox',
'userbox.new.setup': '将 ChuniLumi 或更高版本)的游戏文件夹拖放到下方区域,以显示带有名牌和头像的 UserBox。所有文件都在浏览器中处理。',
'userbox.new.setup.processing_file': '正在处理文件',
'userbox.new.setup.finalizing': '正在保存到内部存储',
'userbox.new.drop': '将游戏文件夹拖到此处',
'userbox.new.activate_first': '启用 AquaBox需要游戏文件',
'userbox.new.activate_update': '更新 AquaBox需要游戏文件',
'userbox.new.activate': '使用 AquaBox',
'userbox.new.activate_desc': '启用后可显示带有名牌和头像的 UserBox',
'userbox.new.error.invalidFolder': '所选文件夹无效。请确认游戏版本为 Lumi 或更新,并且包含 “A001” 选项包。'
};
export const ZH = { ...zhUser, ...zhWelcome, ...zhGeneral,

View File

@@ -0,0 +1,336 @@
/*
A simplified DDS parser with Chusan userbox in mind.
There are some issues on Safari. I don't really care, to be honest.
Authored by Raymond and May.
DDS header parsing based off of https://gist.github.com/brett19/13c83c2e5e38933757c2
*/
import DDSCache from "./ddsCache";
function makeFourCC(string: string) {
return string.charCodeAt(0) +
(string.charCodeAt(1) << 8) +
(string.charCodeAt(2) << 16) +
(string.charCodeAt(3) << 24);
};
/**
* @description Magic bytes for the DDS file format (see https://en.wikipedia.org/wiki/Magic_number_(programming))
*/
const DDS_MAGIC_BYTES = 0x20534444;
/*
to get around the fact that TS's builtin Object.fromEntries() typing
doesn't persist strict types and instead only uses broad types
without creating a new function to get around it...
sorry, this is a really ugly solution, but it's not my problem
*/
/**
* @description List of compression type markers used in DDS
*/
const DDS_COMPRESSION_TYPE_MARKERS = ["DXT1", "DXT3", "DXT5"] as const;
/**
* @description Object mapping string versions of DDS compression type markers to their value in uint32s
*/
const DDS_COMPRESSION_TYPE_MARKERS_MAP = Object.fromEntries(
DDS_COMPRESSION_TYPE_MARKERS
.map(e => [e, makeFourCC(e)] as [typeof e, number])
) as Record<typeof DDS_COMPRESSION_TYPE_MARKERS[number], number>
const DDS_DECOMPRESS_VERTEX_SHADER = `
attribute vec2 aPosition;
varying highp vec2 vTextureCoord;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
vTextureCoord = ((aPosition * vec2(1.0, -1.0)) / 2.0 + 0.5);
}`;
const DDS_DECOMPRESS_FRAGMENT_SHADER = `
varying highp vec2 vTextureCoord;
uniform sampler2D uTexture;
void main() {
gl_FragColor = texture2D(uTexture, vTextureCoord);
}`
export class DDS {
constructor(db: IDBDatabase | undefined) {
this.cache = new DDSCache(db);
let gl = this.canvasGL.getContext("webgl");
if (!gl) throw new Error("Failed to get WebGL rendering context") // TODO: make it switch to Classic userbox
this.gl = gl;
let ctx = this.canvas2D.getContext("2d");
if (!ctx) throw new Error("Failed to reach minimum system requirements") // TODO: make it switch to Classic userbox
this.ctx = ctx;
let ext =
gl.getExtension("WEBGL_compressed_texture_s3tc") ||
gl.getExtension("MOZ_WEBGL_compressed_texture_s3tc") ||
gl.getExtension("WEBKIT_WEBGL_compressed_texture_s3tc");
if (!ext) throw new Error("Browser is not supported."); // TODO: make it switch to Classic userbox
this.ext = ext;
/* Initialize shaders */
this.compileShaders();
this.gl.useProgram(this.shader);
/* Setup position buffer */
let attributeLocation = this.gl.getAttribLocation(this.shader ?? 0, "aPosition");
let positionBuffer = this.gl.createBuffer();
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0]), this.gl.STATIC_DRAW);
this.gl.vertexAttribPointer(
attributeLocation,
2, this.gl.FLOAT,
false, 0, 0
);
this.gl.enableVertexAttribArray(attributeLocation)
}
/**
* @description Loads a DDS file into the internal canvas object.
* @param buffer Uint8Array to load DDS from.
* @returns String if failed to load, void if success
*/
load(buffer: Uint8Array) {
let header = this.loadHeader(buffer);
if (!header) return;
let compressionMode: GLenum = this.ext.COMPRESSED_RGBA_S3TC_DXT1_EXT;
if (header.pixelFormat.flags & 0x4) {
switch (header.pixelFormat.type) {
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT1:
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT1_EXT;
break;
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT3:
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT3_EXT;
break;
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT5:
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT5_EXT;
break;
};
} else return;
/* Initialize and configure the texture */
let texture = this.gl.createTexture();
this.gl.activeTexture(this.gl.TEXTURE0);
this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
this.gl.compressedTexImage2D(
this.gl.TEXTURE_2D,
0,
compressionMode,
header.width,
header.height,
0,
buffer.slice(128)
);
this.gl.uniform1i(this.gl.getUniformLocation(this.shader || 0, "uTexture"), 0);
/* Prepare the canvas for drawing */
this.canvasGL.width = header.width;
this.canvasGL.height = header.height
this.gl.viewport(0, 0, this.canvasGL.width, this.canvasGL.height);
this.gl.clearColor(0.0, 0.0, 0.0, 0.0);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
this.gl.deleteTexture(texture);
};
/**
* @description Export a Blob from the parsed DDS texture
* @returns DDS texture in specified format
* @param inFormat Mime type to export in
*/
getBlob(inFormat?: string): Promise<Blob | null> {
return new Promise(res => this.canvasGL.toBlob(res, inFormat))
}
get2DBlob(inFormat?: string): Promise<Blob | null> {
return new Promise(res => this.canvas2D.toBlob(res, inFormat))
}
/**
* @description Helper function to load in a Blob
* @input Blob to use
*/
async fromBlob(input: Blob) {
this.load(new Uint8Array(await input.arrayBuffer()));
}
/**
* @description Read a DDS file header
* @param buffer Uint8Array of the DDS file's contents
*/
loadHeader(buffer: Uint8Array) {
if (this.getUint32(buffer, 0) !== DDS_MAGIC_BYTES) return;
return {
size: this.getUint32(buffer, 4),
flags: this.getUint32(buffer, 8),
height: this.getUint32(buffer, 12),
width: this.getUint32(buffer, 16),
mipmaps: this.getUint32(buffer, 24),
/* TODO: figure out if we can cut any of this out (we totally can btw) */
pixelFormat: {
size: this.getUint32(buffer, 76),
flags: this.getUint32(buffer, 80),
type: this.getUint32(buffer, 84),
}
}
};
/**
* @description Retrieve a file from the IndexedDB database and load it into the DDS loader
* @param path File path
* @returns Whether or not the attempt to retrieve the file was successful
*/
loadFile(path: string) : Promise<boolean> {
return new Promise(async r => {
let file = await this.cache?.getFromDatabase(path)
if (file != null)
await this.fromBlob(file)
r(file != null)
})
};
/**
* @description Retrieve a file from a path
* @param path File path
* @param fallback Path to a file to fallback to if loading this file fails
* @returns An object URL which correlates to a Blob
*/
async getFile(path: string, fallback?: string) : Promise<string> {
if (this.cache?.cached(path))
return this.cache.find(path) ?? ""
if (!await this.loadFile(path))
if (fallback) {
if (!await this.loadFile(fallback))
return "";
} else
return ""
let blob = await this.getBlob("image/png");
if (!blob) return ""
return this.cache?.save(
path, URL.createObjectURL(blob)
) ?? "";
};
/**
* @description Transform a spritesheet located at a path to match the dimensions specified in the parameters
* @param path Spritesheet path
* @param x Crop: X
* @param y Crop: Y
* @param w Crop: Width
* @param h Crop: Height
* @param s Scale factor
* @returns An object URL which correlates to a Blob
*/
async getFileFromSheet(path: string, x: number, y: number, w: number, h: number, s?: number): Promise<string> {
if (!await this.loadFile(path))
return "";
this.canvas2D.width = w * (s ?? 1);
this.canvas2D.height = h * (s ?? 1);
this.ctx.drawImage(this.canvasGL, x, y, w, h, 0, 0, w * (s ?? 1), h * (s ?? 1));
/* We don't want to cache this, it's a spritesheet piece. */
return URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([]));
};
/**
* @description Retrieve a file and scale it by a specified scale factor
* @param path File path
* @param s Scale factor
* @param fallback Path to a file to fallback to if loading this file fails
* @returns An object URL which correlates to a Blob
*/
async getFileScaled(path: string, s: number, fallback?: string): Promise<string> {
if (this.cache?.cached(path, s))
return this.cache.find(path, s) ?? ""
if (!await this.loadFile(path))
if (fallback) {
if (!await this.loadFile(fallback))
return "";
} else
return "";
this.canvas2D.width = this.canvasGL.width * (s ?? 1);
this.canvas2D.height = this.canvasGL.height * (s ?? 1);
this.ctx.drawImage(this.canvasGL, 0, 0, this.canvasGL.width, this.canvasGL.height, 0, 0, this.canvasGL.width * (s ?? 1), this.canvasGL.height * (s ?? 1));
return this.cache?.save(path, URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([])), s) ?? "";
};
/**
* @description Retrieve a Uint32 from a Uint8Array at the specified offset
* @param buffer Uint8Array to retrieve the Uint32 from
* @param offset Offset at which to retrieve bytes
*/
getUint32(buffer: Uint8Array, offset: number) {
return (buffer[offset + 0] << 0) +
(buffer[offset + 1] << 8) +
(buffer[offset + 2] << 16) +
(buffer[offset + 3] << 24);
};
private compileShaders() {
let vertexShader = this.gl.createShader(this.gl.VERTEX_SHADER);
let fragmentShader = this.gl.createShader(this.gl.FRAGMENT_SHADER);
if (!vertexShader || !fragmentShader) return;
this.gl.shaderSource(vertexShader, DDS_DECOMPRESS_VERTEX_SHADER);
this.gl.compileShader(vertexShader);
if (!this.gl.getShaderParameter(vertexShader, this.gl.COMPILE_STATUS))
throw new Error(
`An error occurred compiling vertex shader: ${this.gl.getShaderInfoLog(vertexShader)}`,
);
this.gl.shaderSource(fragmentShader, DDS_DECOMPRESS_FRAGMENT_SHADER);
this.gl.compileShader(fragmentShader);
if (!this.gl.getShaderParameter(fragmentShader, this.gl.COMPILE_STATUS))
throw new Error(
`An error occurred compiling fragment shader: ${this.gl.getShaderInfoLog(fragmentShader)}`,
);
let program = this.gl.createProgram();
if (!program) return;
this.shader = program;
this.gl.attachShader(program, vertexShader);
this.gl.attachShader(program, fragmentShader);
this.gl.linkProgram(program);
if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS))
throw new Error(
`An error occurred linking the program: ${this.gl.getProgramInfoLog(program)}`,
);
};
canvas2D: HTMLCanvasElement = document.createElement("canvas");
canvasGL: HTMLCanvasElement = document.createElement("canvas");
cache: DDSCache | null;
ctx: CanvasRenderingContext2D;
gl: WebGLRenderingContext;
ext: ReturnType<typeof this.gl.getExtension>;
shader: WebGLShader | null = null;
};

View File

@@ -0,0 +1,64 @@
export default class DDSCache {
constructor(db: IDBDatabase | undefined) {
this.db = db;
}
/**
* @description Finds an object URL for the image with the specified path and scale
* @param path Image path
* @param scale Scale factor
*/
find(path: string, scale: number = 1): string | undefined {
return (this.urlCache.find(
p => p.path == path && p.scale == scale)?.url)
}
/**
* @description Checks whether an object URL is cached for the image with the specified path and scale
* @param path Image path
* @param scale Scale factor
*/
cached(path: string, scale: number = 1): boolean {
return this.urlCache.some(
p => p.path == path && p.scale == scale)
}
/**
* @description Save an object URL for the specified path and scale to the cache
* @param path Image path
* @param url Object URL
* @param scale Scale factor
*/
save(path: string, url: string, scale: number = 1) {
if (this.cached(path, scale)) {
URL.revokeObjectURL(url);
return this.find(path, scale)
}
this.urlCache.push({path, url, scale})
return url
}
/**
* @description Retrieve a Blob from a database based on the specified path
* @param path Image path
*/
getFromDatabase(path: string): Promise<Blob | null> {
return new Promise((resolve, reject) => {
if (!this.db)
return resolve(null);
let transaction = this.db.transaction(["dds"], "readonly");
let objectStore = transaction.objectStore("dds");
let request = objectStore.get(path);
request.onsuccess = async (e) => {
if (request.result)
if (request.result.blob)
return resolve(request.result.blob);
return resolve(null);
}
request.onerror = () => resolve(null);
})
};
private urlCache: {scale: number, path: string, url: string}[] = [];
private db: IDBDatabase | undefined;
}

View File

@@ -0,0 +1,180 @@
import { t, ts } from "../../libs/i18n";
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
const isDirectory = (e: FileSystemEntry): e is FileSystemDirectoryEntry => e.isDirectory
const isFile = (e: FileSystemEntry): e is FileSystemFileEntry => e.isFile
const getDirectory = (directory: FileSystemDirectoryEntry, path: string): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getDirectory(path, {}, d => res(d), e => rej()));
const getFile = (directory: FileSystemDirectoryEntry, path: string): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getFile(path, {}, d => res(d), e => rej()));
const getFiles = async (directory: FileSystemDirectoryEntry): Promise<Array<FileSystemEntry>> => {
let reader = directory.createReader();
let files: Array<FileSystemEntry> = [];
let currentFiles: number = 1e9;
while (currentFiles != 0) {
let entries = await new Promise<Array<FileSystemEntry>>(r => reader.readEntries(r));
files = files.concat(entries);
currentFiles = entries.length;
}
return files;
};
const validateDirectories = async (base: FileSystemDirectoryEntry, path: string): Promise<boolean> => {
const pathTrail = path.split("/");
let directory: FileSystemDirectoryEntry = base;
for (let part of pathTrail) {
let newDirectory = await getDirectory(directory, part).catch(_ => null);
if (newDirectory && isDirectory(newDirectory)) {
directory = newDirectory;
} else
return false;
};
return true
}
const getDirectoryFromPath = async (base: FileSystemDirectoryEntry, path: string): Promise<FileSystemDirectoryEntry | null> => {
const pathTrail = path.split("/");
let directory: FileSystemDirectoryEntry = base;
for (let part of pathTrail) {
let newDirectory = await getDirectory(directory, part).catch(_ => null);
if (newDirectory && isDirectory(newDirectory)) {
directory = newDirectory;
} else
return null;
};
return directory;
}
export let ddsDB: IDBDatabase | undefined ;
/* Technically, processName should be in the translation file but I figured it was such a small thing that it didn't REALLY matter... */
const DIRECTORY_PATHS = ([
{
folder: "ddsImage",
processName: "Characters",
path: "characterThumbnail",
filter: (name: string) => name.substring(name.length - 6, name.length) == "02.dds",
id: (name: string) => `0${name.substring(17, 21)}${name.substring(23, 24)}`
},
{
folder: "namePlate",
processName: "Nameplates",
path: "nameplate",
filter: () => true,
id: (name: string) => name.substring(17, 25)
},
{
folder: "avatarAccessory",
processName: "Avatar Accessory Thumbnails",
path: "avatarAccessoryThumbnail",
filter: (name: string) => name.substring(14, 18) == "Icon",
id: (name: string) => name.substring(19, 27)
},
{
folder: "avatarAccessory",
processName: "Avatar Accessories",
path: "avatarAccessory",
filter: (name: string) => name.substring(14, 17) == "Tex",
id: (name: string) => name.substring(18, 26)
},
{
folder: "texture",
processName: "Surfboard Textures",
useFileName: true,
path: "surfboard",
filter: (name: string) =>
([
"CHU_UI_Common_Avatar_body_00.dds",
"CHU_UI_Common_Avatar_face_00.dds",
"CHU_UI_title_rank_00_v10.dds"
]).includes(name),
id: (name: string) => name
}
] satisfies {folder: string, processName: string, path: string, useFileName?: boolean, filter: (name: string) => boolean, id: (name: string) => string}[] )
export const scanOptionFolder = async (optionFolder: FileSystemDirectoryEntry, progressUpdate: (progress: number, text: string) => void) => {
let filesToProcess: Record<string, FileSystemFileEntry[]> = {};
let directories = (await getFiles(optionFolder))
.filter(directory => isDirectory(directory) && ((directory.name.substring(0, 1) == "A" && directory.name.length == 4) || directory.name == "surfboard"))
for (let directory of directories)
if (isDirectory(directory)) {
for (const directoryData of DIRECTORY_PATHS) {
let folder = await getDirectoryFromPath(directory, directoryData.folder).catch(_ => null) ?? [];
if (folder) {
if (!filesToProcess[directoryData.path])
filesToProcess[directoryData.path] = [];
for (let dataFolderEntry of await getFiles(folder as FileSystemDirectoryEntry).catch(_ => null) ?? [])
if (isDirectory(dataFolderEntry)) {
for (let dataEntry of await getFiles(dataFolderEntry as FileSystemDirectoryEntry).catch(_ => null) ?? [])
if (isFile(dataEntry) && directoryData.filter(dataEntry.name))
filesToProcess[directoryData.path].push(dataEntry);
} else if (isFile(dataFolderEntry) && directoryData.filter(dataFolderEntry.name))
filesToProcess[directoryData.path].push(dataFolderEntry);
}
}
}
let data = [];
for (const [folder, files] of Object.entries(filesToProcess)) {
let reference = DIRECTORY_PATHS.find(r => r.path == folder);
for (const [idx, file] of files.entries()) {
progressUpdate((idx / files.length) * 100, `${t("userbox.new.setup.processing_file")} ${reference?.processName ?? "?"}...`)
data.push({
path: `${folder}:${reference?.id(file.name)}`, name: file.name, blob: await new Promise<File>(res => file.file(res))
});
}
}
progressUpdate(100, `${t("userbox.new.setup.finalizing")}...`)
let transaction = ddsDB?.transaction(['dds'], 'readwrite', { durability: "strict" })
if (!transaction) return; // TODO: bubble error up to user
transaction.onerror = e => e.preventDefault()
let objectStore = transaction.objectStore('dds');
for (let object of data)
objectStore.put(object)
// await transaction completion
await new Promise(r => transaction.addEventListener("complete", r, {once: true}))
};
export function initializeDb() : Promise<void> {
return new Promise(r => {
const dbRequest = indexedDB.open("userboxChusanDDS", 1)
dbRequest.addEventListener("upgradeneeded", (event) => {
if (!(event.target instanceof IDBOpenDBRequest)) return
ddsDB = event.target.result;
if (!ddsDB) return;
const store = ddsDB.createObjectStore('dds', { keyPath: 'path' });
store.createIndex('path', 'path', { unique: true })
store.createIndex('name', 'name', { unique: false })
store.createIndex('blob', 'blob', { unique: false })
r();
});
dbRequest.addEventListener("success", () => {
ddsDB = dbRequest.result;
r();
})
})
}
export async function userboxFileProcess(folder: FileSystemEntry, progressUpdate: (progress: number, progressString: string) => void): Promise<string | null> {
if (!isDirectory(folder))
return t("userbox.new.error.invalidFolder")
if (!(await validateDirectories(folder, "bin/option")) && !(await validateDirectories(folder, "data/A000")))
return t("userbox.new.error.invalidFolder");
initializeDb();
const optionFolder = await getDirectoryFromPath(folder, "bin/option");
if (optionFolder)
await scanOptionFolder(optionFolder, progressUpdate);
const dataFolder = await getDirectoryFromPath(folder, "data");
if (dataFolder)
await scanOptionFolder(dataFolder, progressUpdate);
useLocalStorage("userboxNew", false).value = true;
location.reload();
return null
}

View File

@@ -148,7 +148,7 @@
<div class="rank">
<span>{t('UserHome.ServerRank')}</span>
<span>#{+d.user.serverRank.toLocaleString() + 1}</span>
<span>#{(d.user.serverRank + 1).toLocaleString()}</span>
</div>
</div>

View File

@@ -14,7 +14,7 @@ plugins {
kotlin("plugin.serialization") version ktVer
kotlin("plugin.allopen") version ktVer
id("io.freefair.lombok") version "8.6"
id("org.springframework.boot") version "3.4.1"
id("org.springframework.boot") version "3.2.3"
id("com.github.ben-manes.versions") version "0.51.0"
id("org.hibernate.orm") version "6.4.4.Final"
application
@@ -64,11 +64,11 @@ dependencies {
// =============================
// Network
implementation("io.ktor:ktor-client-core:2.3.13")
implementation("io.ktor:ktor-client-cio:2.3.13")
implementation("io.ktor:ktor-client-content-negotiation:2.3.13")
implementation("io.ktor:ktor-client-encoding:2.3.13")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.13")
implementation("io.ktor:ktor-client-core:2.3.8")
implementation("io.ktor:ktor-client-cio:2.3.8")
implementation("io.ktor:ktor-client-content-negotiation:2.3.8")
implementation("io.ktor:ktor-client-encoding:2.3.8")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.8")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// Somehow these are needed for ktor even though they're not in the documentation

Binary file not shown.

View File

@@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

33
gradlew vendored
View File

@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -83,10 +85,8 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -133,10 +133,13 @@ location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
@@ -144,7 +147,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@@ -152,7 +155,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -197,11 +200,15 @@ if "$cygwin" || "$msys" ; then
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \

22
gradlew.bat vendored
View File

@@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@@ -43,11 +45,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
@@ -57,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail

View File

@@ -6,6 +6,8 @@ import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import jakarta.persistence.Query
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.apache.tika.Tika
@@ -44,6 +46,19 @@ typealias JavaSerializable = java.io.Serializable
typealias JDict = Map<String, Any?>
typealias MutJDict = MutableMap<String, Any?>
fun HttpServletRequest.details() = mapOf(
"method" to method,
"uri" to requestURI,
"query" to queryString,
"remote" to remoteAddr,
"headers" to headerNames.asSequence().associateWith { getHeader(it) }
)
fun HttpServletResponse.details() = mapOf(
"status" to status,
"headers" to headerNames.asSequence().associateWith { getHeader(it) },
)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.PROPERTY_GETTER)
@Retention(AnnotationRetention.RUNTIME)
annotation class Doc(

View File

@@ -24,6 +24,7 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
abstract val playlogRepo: GenericPlaylogRepo<*>
abstract val shownRanks: List<Pair<Int, String>>
abstract val settableFields: Map<String, (T, String) -> Unit>
open val gettableFields: Set<String> = setOf()
@API("trend")
abstract suspend fun trend(@RP username: String): List<TrendOut>
@@ -110,7 +111,8 @@ abstract class GameApiController<T : IUserData>(val name: String, userDataClass:
fun playlog(@RP id: Long): IGenericGamePlaylog = playlogRepo.findById(id).getOrNull() ?: (404 - "Playlog not found")
val userDetailFields by lazy { userDataClass.gettersMap().let { vm ->
settableFields.map { (k, _) -> k to (vm[k] ?: error("Field $k not found")) }.toMap()
(settableFields.keys.toSet() + gettableFields)
.associateWith { k -> (vm[k] ?: error("Field $k not found")) }
} }
@API("user-detail")

View File

@@ -38,6 +38,7 @@ class Chusan(
"avatarFront" to { u, v -> u.avatarFront = v.int },
"avatarBack" to { u, v -> u.avatarBack = v.int },
) }
override val gettableFields: Set<String> = setOf("level", "playerRating", "characterId")
override suspend fun userSummary(@RP username: Str, @RP token: String?) = us.cardByName(username) { card ->
// Summary values: total plays, player rating, server-wide ranking

View File

@@ -1,6 +1,9 @@
package icu.samnyan.aqua.sega.general.filter
import ext.details
import ext.logger
import ext.toJson
import icu.samnyan.aqua.sega.allnet.TokenChecker
import icu.samnyan.aqua.sega.util.ZLib
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
@@ -35,9 +38,22 @@ class CompressionFilter : OncePerRequestFilter() {
}
// Handle request
val result = ContentCachingResponseWrapper(resp).run {
chain.doFilter(CompressRequestWrapper(req, reqSrc), this)
ZLib.compress(contentAsByteArray).let { if (isDfi) b64e.encode(it) else it }
val respW = ContentCachingResponseWrapper(resp)
val result = try {
chain.doFilter(CompressRequestWrapper(req, reqSrc), respW)
ZLib.compress(respW.contentAsByteArray).let { if (isDfi) b64e.encode(it) else it }
} finally {
if (respW.status != 200) {
val details = mapOf(
"req" to req.details(),
"resp" to respW.details(),
"body" to reqSrc.toString(Charsets.UTF_8),
"result" to respW.contentAsByteArray.toString(Charsets.UTF_8),
"token" to TokenChecker.getCurrentSession()?.token
).toJson()
log.error("HTTP ${respW.status}: $details")
}
}
// Write response

View File

@@ -363,7 +363,7 @@ class Maimai2ServletController(
return try {
Metrics.timer("aquadx_maimai2_api_latency", "api" to api).recordCallable {
handlers[api]!!.handle(request).let { if (it is String) it else it.toJson() }.also {
if (api !in setOf("GetUserItemApi", "GetGameEventApi"))
if (api !in setOf("GetUserItemApi", "GetGameEventApi", "GetUserPortraitApi"))
logger.info("Mai2 > $api : $it")
}
}

View File

@@ -0,0 +1,131 @@
# Delete duplicate rows
DELETE FROM maimai2_user_extend
WHERE id NOT IN (
SELECT MAX(id)
FROM maimai2_user_extend
GROUP BY user_id
);
DELETE FROM maimai2_user_option
WHERE id NOT IN (
SELECT MAX(id)
FROM maimai2_user_option
GROUP BY user_id
);
DELETE FROM maimai2_user_character
WHERE id NOT IN (
SELECT MAX(id)
FROM maimai2_user_character
GROUP BY user_id, character_id
);
DELETE FROM maimai2_user_map
WHERE id NOT IN (
SELECT MAX(id)
FROM maimai2_user_map
GROUP BY user_id, map_id
);
DELETE FROM maimai2_user_login_bonus
WHERE id NOT IN (
SELECT MAX(id)
FROM maimai2_user_login_bonus
GROUP BY user_id, bonus_id
);
DELETE FROM maimai2_user_udemae
WHERE id NOT IN (
SELECT MAX(id)
FROM maimai2_user_udemae
GROUP BY user_id
);
DELETE FROM maimai2_user_general_data
WHERE id NOT IN (
SELECT MAX(id)
FROM maimai2_user_general_data
GROUP BY user_id, property_key
);
DELETE FROM maimai2_user_item
WHERE id NOT IN (
SELECT MAX(id)
FROM maimai2_user_item
GROUP BY user_id, item_id, item_kind
);
DELETE FROM maimai2_user_music_detail
WHERE id NOT IN (
SELECT MAX(id)
FROM maimai2_user_music_detail
GROUP BY user_id, music_id, level
);
DELETE FROM maimai2_user_course
WHERE id NOT IN (
SELECT MAX(id)
FROM maimai2_user_course
GROUP BY user_id, course_id
);
DELETE FROM maimai2_user_friend_season_ranking
WHERE id NOT IN (
SELECT MAX(id)
FROM maimai2_user_friend_season_ranking
GROUP BY user_id, season_id
);
DELETE FROM maimai2_user_favorite
WHERE id NOT IN (
SELECT MAX(id)
FROM maimai2_user_favorite
GROUP BY user_id, item_kind
);
DELETE FROM maimai2_user_activity
WHERE id NOT IN (
SELECT MAX(id)
FROM maimai2_user_activity
GROUP BY user_id, activity_id, kind
);
# Add unique constraint
ALTER TABLE maimai2_user_extend
ADD CONSTRAINT unique_user_extend UNIQUE (user_id);
ALTER TABLE maimai2_user_option
ADD CONSTRAINT unique_user_option UNIQUE (user_id);
ALTER TABLE maimai2_user_character
ADD CONSTRAINT unique_user_character UNIQUE (user_id, character_id);
ALTER TABLE maimai2_user_map
ADD CONSTRAINT unique_user_map UNIQUE (user_id, map_id);
ALTER TABLE maimai2_user_login_bonus
ADD CONSTRAINT unique_user_login_bonus UNIQUE (user_id, bonus_id);
ALTER TABLE maimai2_user_udemae
ADD CONSTRAINT unique_user_udemae UNIQUE (user_id);
ALTER TABLE maimai2_user_general_data
ADD CONSTRAINT unique_user_general_data UNIQUE (user_id, property_key);
ALTER TABLE maimai2_user_item
ADD CONSTRAINT unique_user_item UNIQUE (user_id, item_id, item_kind);
ALTER TABLE maimai2_user_music_detail
ADD CONSTRAINT unique_user_music_detail UNIQUE (user_id, music_id, level);
ALTER TABLE maimai2_user_course
ADD CONSTRAINT unique_user_course UNIQUE (user_id, course_id);
ALTER TABLE maimai2_user_friend_season_ranking
ADD CONSTRAINT unique_user_friend_season_ranking UNIQUE (user_id, season_id);
ALTER TABLE maimai2_user_favorite
ADD CONSTRAINT unique_user_favorite UNIQUE (user_id, item_kind);
ALTER TABLE maimai2_user_activity
ADD CONSTRAINT unique_user_activity UNIQUE (user_id, activity_id, kind);