Add customizability to weapon widget

This commit is contained in:
Kalle
2026-09-08 20:37:36 +03:00
parent ac9eb3723b
commit ac8ef0630d
33 changed files with 172 additions and 9 deletions

View File

@@ -269,7 +269,7 @@ export function nzapWidgets(): StoredWidget[] {
settings: { controller: "s2-pro-con", motionSens: 50, stickSens: 5 },
},
{ id: "social-links" },
{ id: "weapon-pool" },
{ id: "weapon-pool", settings: { weaponPool: [] } },
{ id: "map-mode-preferences" },
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
{ id: "trophies-owned" },

View File

@@ -54,7 +54,9 @@ describe("PlusVotingRepository.findAllUsersForVoting", () => {
});
test("returns no bio for a user without a bio widget", async () => {
const bio = await bioOf([{ id: "weapon-pool" }]);
const bio = await bioOf([
{ id: "weapon-pool", settings: { weaponPool: [] } },
]);
expect(bio).toBeNull();
});

View File

@@ -82,6 +82,17 @@ export async function isPlayerLinkedByUserId(userId: number): Promise<boolean> {
return Boolean(player);
}
/** Weapons the user has a ten-star badge for, from their linked X Rank placements. */
export async function findTenStarWeaponSplIdsByUserId(userId: number) {
const rows = await db
.selectFrom("TenStarWeapon")
.select("TenStarWeapon.weaponSplId")
.where("TenStarWeapon.userId", "=", userId)
.execute();
return rows.map((row) => row.weaponSplId);
}
/** From the linked player's denormalized `SplatoonPlayer.peakXp` (see {@link refreshAllPeakXp}); `null` without one. */
export async function findPeakVerifiedXpByUserId(
userId: Tables["User"]["id"],

View File

@@ -519,7 +519,7 @@ describe("UserRepository", () => {
describe("UserRepository.findStoredWidgetsByUserId", () => {
const sixMainWidgets: Parameters<typeof UserFactory.create>[1] = {
widgets: [
{ id: "weapon-pool" },
{ id: "weapon-pool", settings: { weaponPool: [] } },
{ id: "trophies-owned" },
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
{ id: "badges-authored" },

View File

@@ -85,6 +85,8 @@ function WidgetFormFields({ widgetId }: { widgetId: string }) {
);
case "peak-xp-weapon":
return <FormField name="weaponSplId" />;
case "weapon-pool":
return <FormField name="weaponPool" />;
case "sens":
return <SensFields />;
case "art":

View File

@@ -299,8 +299,22 @@ export const WIDGET_LOADERS = {
commissions: async (userId: number) => {
return UserRepository.findCommissionsByUserId(userId);
},
"weapon-pool": async (userId: number) => {
return MatchProfileRepository.findWeaponPoolByUserId(userId);
"weapon-pool": async (
userId: number,
settings: ExtractWidgetSettings<"weapon-pool">,
) => {
if (settings.weaponPool.length === 0) {
return MatchProfileRepository.findWeaponPoolByUserId(userId);
}
const tenStarWeaponSplIds =
await XRankPlacementRepository.findTenStarWeaponSplIdsByUserId(userId);
return settings.weaponPool.map((weapon) => ({
weaponSplId: weapon.id,
isFavorite: weapon.isFavorite ? 1 : 0,
isTenStar: tenStarWeaponSplIds.includes(weapon.id) ? 1 : 0,
}));
},
"social-links": async (userId: number) => {
return UserRepository.findSocialLinksByUserId(userId);

View File

@@ -3,7 +3,7 @@ import { widgetsAvailableTo } from "./portfolio";
import type { StoredWidget } from "./types";
const MAIN_WIDGETS: StoredWidget[] = [
{ id: "weapon-pool" },
{ id: "weapon-pool", settings: { weaponPool: [] } },
{ id: "trophies-owned" },
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
{ id: "badges-authored" },

View File

@@ -19,6 +19,7 @@ import {
sensSchema,
tierListSchema,
timezoneSchema,
weaponPoolWidgetSchema,
xRankPeaksSchema,
} from "./widget-form-schemas";
@@ -54,7 +55,12 @@ export const ALL_WIDGETS = {
schema: favoriteStageSchema,
defaultSettings: { stageId: 1 },
}),
defineWidget({ id: "weapon-pool", slot: "main" }),
defineWidget({
id: "weapon-pool",
slot: "main",
schema: weaponPoolWidgetSchema,
defaultSettings: { weaponPool: [] },
}),
defineWidget({ id: "lfg-posts", slot: "main", navItem: "lfg" }),
defineWidget({
id: "sens",
@@ -239,7 +245,7 @@ export const ALL_WIDGETS = {
* showed before it was widget based.
*/
export const DEFAULT_WIDGETS: StoredWidget[] = [
{ id: "weapon-pool" },
{ id: "weapon-pool", settings: { weaponPool: [] } },
{ id: "x-rank-peaks", settings: { division: "both" } },
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
{ id: "bio", settings: { bio: null } },

View File

@@ -15,6 +15,7 @@ import {
textArea,
textAreaOptional,
textField,
weaponPool,
weaponSelect,
} from "~/form/fields";
import type { FormObjectSchema, SelectOption } from "~/form/types";
@@ -94,6 +95,14 @@ export const peakXpWeaponSchema = v.object({
}),
});
export const weaponPoolWidgetSchema = v.object({
weaponPool: weaponPool({
label: "labels.weaponPool",
bottomText: "bottomTexts.weaponPoolWidget",
maxCount: USER.WEAPON_POOL_WIDGET_MAX,
}),
});
const CONTROLLERS = [
"s1-pro-con",
"s2-pro-con",
@@ -203,6 +212,7 @@ const WIDGET_FORM_SCHEMAS: Record<string, FormObjectSchema> = {
"favorite-stage": favoriteStageSchema,
"peak-xp-unverified": peakXpUnverifiedSchema,
"peak-xp-weapon": peakXpWeaponSchema,
"weapon-pool": weaponPoolWidgetSchema,
sens: sensSchema,
art: artSchema,
links: linksSchema,

View File

@@ -14,6 +14,7 @@ export const USER = {
MAX_SIDE_WIDGETS_SUPPORTER: 7,
GAME_BADGES_MAX: 8,
GAME_BADGES_SMALL_MAX: 4,
WEAPON_POOL_WIDGET_MAX: 7,
COUNTDOWN_TITLE_MAX_LENGTH: 50,
MARKDOWN_WIDGET_MAX_LENGTH: 2000,
PEAK_XP_MIN: 1000,

View File

@@ -54,6 +54,30 @@ describe("widgetsEditSchema", () => {
settings: { controller: "s1-pro-con", motionSens: 51, stickSens: null },
},
},
{
why: "the same weapon twice in the weapon pool widget",
widget: {
id: "weapon-pool",
settings: {
weaponPool: [
{ id: 40, isFavorite: false },
{ id: 40, isFavorite: true },
],
},
},
},
{
why: "more weapons than the weapon pool widget allows",
widget: {
id: "weapon-pool",
settings: {
weaponPool: [0, 10, 20, 30, 40, 50, 60, 70].map((id) => ({
id,
isFavorite: false,
})),
},
},
},
])("rejects $why", ({ widget }) => {
const result = v.safeParse(widgetsEditSchema(true), {
widgets: JSON.stringify([widget]),
@@ -81,6 +105,17 @@ describe("widgetsEditSchema", () => {
settings: { controller: "s1-pro-con", motionSens: -25, stickSens: 5 },
},
},
{
why: "an empty weapon pool widget list",
widget: { id: "weapon-pool", settings: { weaponPool: [] } },
},
{
why: "a weapon pool widget list with a favorite",
widget: {
id: "weapon-pool",
settings: { weaponPool: [{ id: 40, isFavorite: true }] },
},
},
])("accepts $why", ({ widget }) => {
const result = v.safeParse(widgetsEditSchema(true), {
widgets: JSON.stringify([widget]),

View File

@@ -0,0 +1,5 @@
---
navItem: u
type: feature
---
Weapon pool widget: choose the weapons to show (up to 7), or leave it empty to keep showing your match profile pool

View File

@@ -52,6 +52,16 @@ export class UserEditWidgetsPage {
await this.locators.badgesSelector.selectOption(String(badgeId));
}
/** Adds one weapon to the weapon pool widget's settings, expanded after opening them. */
async selectWeaponPoolWeapon(weaponName: string) {
await this.page.getByTestId("weapon-select").click();
await this.page.getByPlaceholder("Search weapons...").fill(weaponName);
await this.page
.getByRole("listbox")
.getByTestId(`weapon-select-option-${weaponName}`)
.click();
}
/** Fills the bio widget's settings, expanded after adding it or opening them. */
async fillBio(text: string) {
await this.page.getByLabel("Bio").fill(text);

View File

@@ -215,6 +215,33 @@ test.describe("User page", () => {
}
});
test("shows the weapon pool widget's own list over the match profile pool", async ({
page,
factories,
}) => {
await factories.UserFactory.grant(ADMIN_ID, {
matchProfile: {
weaponPool: [{ id: 1100, isFavorite: false }],
},
});
await impersonate(page);
const editWidgetsPage = new UserEditWidgetsPage(page);
await editWidgetsPage.goto(ADMIN_DISCORD_ID);
await editWidgetsPage.openWidgetSettings("weapon-pool");
await editWidgetsPage.selectWeaponPoolWeapon("Luna Blaster");
await editWidgetsPage.selectWeaponPoolWeapon("Splattershot");
await editWidgetsPage.save();
const userPage = new UserPage(page);
await userPage.goto(ADMIN_DISCORD_ID);
await expect(userPage.weaponPoolImage(200, 1)).toBeVisible();
await expect(userPage.weaponPoolImage(40, 2)).toBeVisible();
await isNotVisible(userPage.weaponPoolImage(1100, 1));
});
test("chooses result highlights which the results list then shows by default", async ({
page,
factories,

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Våbenpulje",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Waffenpool",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "Name can't be only special characters",
"errors.customRoleRequired": "Enter a name for the custom role",
"labels.weaponPool": "Weapon pool",
"bottomTexts.weaponPoolWidget": "Leave empty to show your match profile weapon pool",
"placeholders.chatMessage": "Press enter to send",
"placeholders.weaponPoolFull": "Pool full - remove a weapon to add more",
"placeholders.vodStartTimestamp": "10:22",

View File

@@ -127,7 +127,7 @@
"widgets.description.top-500-weapons-splatanas": "Show splatanas you've reached top 500 with",
"widgets.description.x-rank-peaks": "Show your peak X Rank placement for each mode, optionally select division",
"widgets.description.builds": "Display your 3 most recent builds",
"widgets.description.weapon-pool": "Display your match profile weapon pool (edit on settings page)",
"widgets.description.weapon-pool": "Display your match profile weapon pool or a custom list of weapons",
"widgets.description.sens": "Show your sensitivity settings and controller of choice",
"widgets.description.art": "Display your 3 most recent art pieces",
"widgets.description.commissions": "Show your commission status and details",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "El nombre no puede ser solo caracteres especiales",
"errors.customRoleRequired": "Introduce un nombre para el rol personalizado",
"labels.weaponPool": "Selección de armas",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "Presiona Enter para enviar",
"placeholders.weaponPoolFull": "Selección llena - elimina un arma para añadir más",
"placeholders.vodStartTimestamp": "10:22",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "El nombre no puede ser solo caracteres especiales",
"errors.customRoleRequired": "Introduce un nombre para el rol personalizado",
"labels.weaponPool": "Grupo de armas",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "Presionar 'enter' para enviar",
"placeholders.weaponPoolFull": "Selección llena - elimina un arma para añadir más",
"placeholders.vodStartTimestamp": "10:22",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Armes jouées",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Armes jouées",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "Appuyer sur entrer pour envoyer",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "מאגר נשקים",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Pool armi",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "Premi Invio per inviare",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "使用ブキ",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "送信するには enter を押してください",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Pula broni",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Seleção de armas",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "Aperte enter para enviar",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Используемое оружие",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "Нажмите enter, чтобы отправить",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",

View File

@@ -75,6 +75,7 @@
"errors.noOnlySpecialCharacters": "名称不能仅由特殊字符组成",
"errors.customRoleRequired": "请输入自定义职责的名称",
"labels.weaponPool": "武器池",
"bottomTexts.weaponPoolWidget": "",
"placeholders.chatMessage": "按回车键发送",
"placeholders.weaponPoolFull": "武器池已满。请移除一个武器以添加新武器",
"placeholders.vodStartTimestamp": "",

View File

@@ -0,0 +1,18 @@
import { type Kysely, sql } from "kysely";
/**
* The weapon pool widget gained its own optional weapon list. Rows saved before
* that have no settings, so they get an empty list which keeps showing the match
* profile pool as before.
*/
export async function up(db: Kysely<any>): Promise<void> {
await sql`
update "UserWidget"
set "widget" = json_object(
'id', 'weapon-pool',
'settings', json_object('weaponPool', json('[]'))
)
where json_extract("widget", '$.id') = 'weapon-pool'
and json_extract("widget", '$.settings') is null
`.execute(db);
}

View File

@@ -880,6 +880,12 @@ export function buildCases(fx: Fixtures): {
add("XRankPlacementRepository.isPlayerLinkedByUserId", fx.xrank, (xrank) =>
XRankPlacementRepository.isPlayerLinkedByUserId(xrank.userId),
);
add(
"XRankPlacementRepository.findTenStarWeaponSplIdsByUserId",
fx.xrank,
(xrank) =>
XRankPlacementRepository.findTenStarWeaponSplIdsByUserId(xrank.userId),
);
add(
"XRankPlacementRepository.findPeakVerifiedXpByUserId",
fx.xrank,