Remove unused

This commit is contained in:
Kalle
2026-11-01 10:59:34 +02:00
parent 8d06993e2f
commit 9aa5fcbecf
21 changed files with 117 additions and 553 deletions

View File

@@ -25,7 +25,6 @@ export const JSON_COLUMNS: ReadonlySet<string> = new Set([
"Tournament.seedingSnapshot",
"Tournament.settings",
"TournamentAuditLog.metadata",
"TournamentBroadcast.overrides",
"TournamentBroadcast.settings",
"TournamentBroadcast.state",
"TournamentMatch.opponentOne",

View File

@@ -30,7 +30,6 @@ import type {
} from "~/db/tables-json";
import type { ApiTokenType } from "~/features/api/api-types";
import type {
BroadcastOverrides,
BroadcastSettings,
BroadcastStoredState,
} from "~/features/broadcast/broadcast-types";
@@ -1248,7 +1247,6 @@ export interface TournamentBroadcast {
/** Theme, not staged */
settings: JSONColumnType<BroadcastSettings>;
/** Team and player values the producer edited over the tournament's, not staged */
overrides: JSONColumnType<BroadcastOverrides>;
/** Chat of the tournament's organizers and streamers producing the broadcast, created once the cast account is set */
chatRoomId: number | null;
createdAt: Generated<number>;

View File

@@ -74,7 +74,7 @@ describe("BroadcastRepository.updateSettings", () => {
});
await BroadcastRepository.updateSettings({
...key,
patch: { headline: "Finals at 18:00", casters: ["Sendou", "Kalle"] },
patch: { headline: "Finals at 18:00", casterIds: [1, 2] },
});
const broadcast = await BroadcastRepository.findByChannel(key);
@@ -82,7 +82,7 @@ describe("BroadcastRepository.updateSettings", () => {
css: "body { color: red; }",
stinger: "burst",
headline: "Finals at 18:00",
casters: ["Sendou", "Kalle"],
casterIds: [1, 2],
});
});
});
@@ -130,64 +130,6 @@ describe("BroadcastRepository.replaceState", () => {
});
});
describe("BroadcastRepository.updateTeamOverrides", () => {
beforeEach(async () => {
await users.create(1);
});
test("keeps the overrides of other teams", async () => {
const key = await createBroadcastKey();
await BroadcastRepository.updateTeamOverrides({
...key,
teamId: 1,
team: { name: "Team One" },
players: [{ userId: 10, override: { weapons: [0, 10] } }],
});
await BroadcastRepository.updateTeamOverrides({
...key,
teamId: 2,
team: { name: "Team Two" },
players: [{ userId: 20, override: { name: "Twenty" } }],
});
const broadcast = await BroadcastRepository.findByChannel(key);
expect(broadcast?.overrides).toEqual({
teams: { 1: { name: "Team One" }, 2: { name: "Team Two" } },
players: { 10: { weapons: [0, 10] }, 20: { name: "Twenty" } },
});
});
test("removes the entries set to null", async () => {
const key = await createBroadcastKey();
await BroadcastRepository.updateTeamOverrides({
...key,
teamId: 1,
team: { name: "Team One" },
players: [
{ userId: 10, override: { name: "Ten" } },
{ userId: 11, override: { name: "Eleven" } },
],
});
await BroadcastRepository.updateTeamOverrides({
...key,
teamId: 1,
team: null,
players: [
{ userId: 10, override: null },
{ userId: 11, override: { name: "Eleven" } },
],
});
const broadcast = await BroadcastRepository.findByChannel(key);
expect(broadcast?.overrides).toEqual({
teams: {},
players: { 11: { name: "Eleven" } },
});
});
});
describe("BroadcastRepository.updateLiveResults", () => {
beforeEach(async () => {
await users.create(1);

View File

@@ -10,15 +10,12 @@ import {
} from "~/utils/kysely.server";
import {
BROADCAST_WEAPONS_PER_PLAYER,
DEFAULT_BROADCAST_OVERRIDES,
DEFAULT_BROADCAST_SETTINGS,
} from "./broadcast-constants";
import type {
BroadcastChannel,
BroadcastPlayerOverride,
BroadcastSettings,
BroadcastStoredState,
BroadcastTeamOverride,
LiveResults,
ProducerState,
} from "./broadcast-types";
@@ -29,14 +26,13 @@ interface BroadcastKey {
channel: Pick<BroadcastChannel, "platform" | "account">;
}
/** Producer state, settings, edited values and chat room of the channel's broadcast, `undefined` before anything was written to it. */
/** Producer state, settings and chat room of the channel's broadcast, `undefined` before anything was written to it. */
export function findByChannel({ tournamentId, channel }: BroadcastKey) {
return db
.selectFrom("TournamentBroadcast")
.select([
"TournamentBroadcast.state",
"TournamentBroadcast.settings",
"TournamentBroadcast.overrides",
"TournamentBroadcast.chatRoomId",
])
.where("TournamentBroadcast.tournamentId", "=", tournamentId)
@@ -76,6 +72,17 @@ export async function findAllByChatRoomIds(chatRoomIds: number[]) {
.execute();
}
/** Casters by their user ids, in no particular order. */
export function findAllCastersByUserIds(userIds: number[]) {
if (userIds.length === 0) return Promise.resolve([]);
return db
.selectFrom("User")
.select(["User.id as userId", "User.username as name", "User.pronouns"])
.where("User.id", "in", userIds)
.execute();
}
// xxx: do we want to show country?
/** Members of the teams with their top weapons (favorites first, then the pool's order), owner first then in joining order. */
export function findAllPlayersByTournamentTeamIds(tournamentTeamIds: number[]) {
@@ -195,55 +202,6 @@ export async function updateSettings({
.execute();
}
/**
* Sets the edited values of one team and its players key by key, so edits of other teams made meanwhile stay.
* `null` removes the entry, going back to the tournament's values.
*/
export async function updateTeamOverrides({
teamId,
team,
players,
...key
}: BroadcastKey & {
teamId: number;
team: BroadcastTeamOverride | null;
players: Array<{ userId: number; override: BroadcastPlayerOverride | null }>;
}) {
await insertIfMissing(key);
const entries = [
{ path: `$.teams."${teamId}"`, value: team },
...players.map((player) => ({
path: `$.players."${player.userId}"`,
value: player.override,
})),
];
const removed = entries.filter((entry) => entry.value === null);
const set = entries.filter((entry) => entry.value !== null);
let overrides = sql<string>`"TournamentBroadcast"."overrides"`;
if (removed.length > 0) {
overrides = sql<string>`json_remove(${overrides}, ${sql.join(
removed.map((entry) => sql`${entry.path}`),
)})`;
}
if (set.length > 0) {
overrides = sql<string>`json_set(${overrides}, ${sql.join(
set.map(
(entry) => sql`${entry.path}, json(${JSON.stringify(entry.value)})`,
),
)})`;
}
await db
.updateTable("TournamentBroadcast")
.set({ overrides })
.where("TournamentBroadcast.tournamentId", "=", key.tournamentId)
.where("TournamentBroadcast.platform", "=", key.channel.platform)
.where("TournamentBroadcast.account", "=", key.channel.account)
.execute();
}
/**
* Gives the channel's broadcast its chat room, creating the broadcast too when nothing was written to it yet.
* Returns the room's id and whether it was created now.
@@ -295,7 +253,6 @@ function insertIfMissing(
state: JSON.stringify(Producer.initialState()),
// xxx: autofill settings (theme) from the same channel's broadcast in the last tournament of this series
settings: JSON.stringify(DEFAULT_BROADCAST_SETTINGS),
overrides: JSON.stringify(DEFAULT_BROADCAST_OVERRIDES),
})
.onConflict((oc) =>
oc.columns(["tournamentId", "platform", "account"]).doNothing(),

View File

@@ -10,10 +10,8 @@ import {
} from "../broadcast-schemas";
import { requireBroadcastProducer } from "../broadcast-utils.server";
import * as BroadcastHub from "../core/BroadcastHub.server";
import * as BroadcastSnapshot from "../core/BroadcastSnapshot.server";
import * as CastChannel from "../core/CastChannel";
import * as Producer from "../core/Producer";
import * as TeamOverrides from "../core/TeamOverrides";
const STALE_TAKE_MESSAGE =
"Someone else took in the meantime, check what is live now";
@@ -87,46 +85,6 @@ export const action = defineAction(
});
break;
}
case "UPDATE_TEAM": {
const defaults = notFoundIfNullish(
await BroadcastSnapshot.findTeamValues({
tournament,
teamId: body.teamId,
}),
);
await BroadcastRepository.updateTeamOverrides({
...key,
teamId: body.teamId,
...TeamOverrides.fromSubmitted({
defaults,
submitted: {
name: body.name,
players: body.players.map((player) => ({
userId: player.userId,
name: player.name,
weapons: player.weapons.map((weapon) => weapon.id),
})),
},
}),
});
break;
}
case "RESET_TEAM": {
const defaults = notFoundIfNullish(
await BroadcastSnapshot.findTeamValues({
tournament,
teamId: body.teamId,
}),
);
await BroadcastRepository.updateTeamOverrides({
...key,
teamId: body.teamId,
...TeamOverrides.fromSubmitted({ defaults, submitted: defaults }),
});
break;
}
case "UPDATE_THEME": {
await BroadcastRepository.updateSettings({
...key,
@@ -137,7 +95,7 @@ export const action = defineAction(
case "UPDATE_INFO": {
await BroadcastRepository.updateSettings({
...key,
patch: { headline: body.headline ?? "", casters: body.casters },
patch: { headline: body.headline ?? "", casterIds: body.casterIds },
});
break;
}

View File

@@ -160,7 +160,14 @@ export const BROADCAST_SCENE_PARTS = {
"stage",
"winner",
],
casters: ["header", "tournament-name", "title", "camera", "caster-name"],
casters: [
"header",
"tournament-name",
"title",
"camera",
"caster-name",
"caster-pronouns",
],
replays: ["corner", "replay-bug", "replay-dot", "replay-label", "game"],
hold: [
"tournament-logo",
@@ -218,7 +225,7 @@ export const DEFAULT_BROADCAST_SETTINGS = {
css: "",
stinger: "versus",
headline: "",
casters: [],
casterIds: [],
} as const satisfies BroadcastSettings;
export const BROADCAST_HEADLINE_MAX_LENGTH = 100;
@@ -226,17 +233,8 @@ export const BROADCAST_HEADLINE_MAX_LENGTH = 100;
// xxx: or 2?
export const BROADCAST_MAX_CASTERS = 3;
export const BROADCAST_CASTER_NAME_MAX_LENGTH = 40;
export const DEFAULT_BROADCAST_OVERRIDES = { teams: {}, players: {} } as const;
export const BROADCAST_WEAPONS_PER_PLAYER = 3;
export const BROADCAST_PLAYER_NAME_MAX_LENGTH = 32;
/** Upper bound of the edit form's roster, rosters are capped well below by the tournament. */
export const BROADCAST_MAX_PLAYERS_PER_TEAM = 20;
/** Days the producers' chat lives from the tournament's start, or from when the channel was set if that's later. */
export const BROADCAST_CHAT_ROOM_LIFESPAN_DAYS = 7;

View File

@@ -1,29 +1,21 @@
import * as v from "valibot";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import {
array,
fieldset,
idConstant,
select,
stringConstant,
textAreaOptional,
textField,
textFieldOptional,
weaponPool,
userSearch,
} from "~/form/fields";
import { _action, coerceNumber, id } from "~/utils/schema";
import {
BROADCAST_CASTER_NAME_MAX_LENGTH,
BROADCAST_CSS_MAX_LENGTH,
BROADCAST_HEADLINE_MAX_LENGTH,
BROADCAST_INK_COLORS,
BROADCAST_MAX_CASTERS,
BROADCAST_MAX_PLAYERS_PER_TEAM,
BROADCAST_PLAYER_NAME_MAX_LENGTH,
BROADCAST_SCENES,
BROADCAST_STINGERS,
BROADCAST_TRANSITIONS,
BROADCAST_WEAPONS_PER_PLAYER,
} from "./broadcast-constants";
const nonNegativeInteger = v.pipe(coerceNumber(), v.integer(), v.minValue(0));
@@ -61,39 +53,11 @@ export const broadcastInfoFormSchema = v.object({
bottomText: "bottomTexts.broadcastHeadline",
maxLength: BROADCAST_HEADLINE_MAX_LENGTH,
}),
casters: array({
casterIds: array({
label: "labels.broadcastCasters",
bottomText: "bottomTexts.broadcastCasters",
max: BROADCAST_MAX_CASTERS,
field: textField({ maxLength: BROADCAST_CASTER_NAME_MAX_LENGTH }),
}),
});
export const broadcastTeamFormSchema = v.object({
_action: stringConstant("UPDATE_TEAM"),
teamId: idConstant(),
name: textField({
label: "labels.teamName",
maxLength: TOURNAMENT.TEAM_NAME_MAX_LENGTH,
}),
players: array({
label: "labels.broadcastPlayers",
max: BROADCAST_MAX_PLAYERS_PER_TEAM,
addable: false,
field: fieldset({
fields: v.object({
userId: idConstant(),
name: textField({
label: "labels.name",
maxLength: BROADCAST_PLAYER_NAME_MAX_LENGTH,
}),
weapons: weaponPool({
label: "labels.broadcastWeapons",
maxCount: BROADCAST_WEAPONS_PER_PLAYER,
disableFavorites: true,
}),
}),
}),
field: userSearch({ label: "labels.user" }),
}),
});
@@ -122,8 +86,6 @@ export const broadcastActionSchema = v.union([
matchId: id,
resultCount: nonNegativeInteger,
}),
v.object({ _action: _action("RESET_TEAM"), teamId: id }),
broadcastThemeFormSchema,
broadcastInfoFormSchema,
broadcastTeamFormSchema,
]);

View File

@@ -1,3 +1,4 @@
import type { Pronouns } from "~/db/tables-json";
import type { StreamPlatform } from "~/features/live-streams/live-streams-constants";
import type {
MainWeaponId,
@@ -67,25 +68,8 @@ export interface BroadcastSettings {
stinger: BroadcastStinger;
/** Message of the full screen scenes, e.g. "Top 8 starts at 18:00 CEST". */
headline: string;
/** Names under the camera frames of the casters scene, one frame per caster. */
casters: string[];
}
/** Team and player values the producer edited, shown instead of the tournament's in both live and next. */
export interface BroadcastOverrides {
/** By tournament team id. */
teams: Record<number, BroadcastTeamOverride>;
/** By user id. */
players: Record<number, BroadcastPlayerOverride>;
}
export interface BroadcastTeamOverride {
name?: string;
}
export interface BroadcastPlayerOverride {
name?: string;
weapons?: MainWeaponId[];
/** Users under the camera frames of the casters scene, one frame per caster. */
casterIds: number[];
}
/** The cast account a broadcast is for. */
@@ -101,6 +85,8 @@ export interface BroadcastChannel {
export interface BroadcastState extends BroadcastStoredState {
v: 1;
settings: BroadcastSettings;
/** Users of `settings.casterIds` in order, ones that no longer exist left out. */
casters: BroadcastCaster[];
tournament: {
name: string;
logoUrl: string;
@@ -163,8 +149,12 @@ export interface BroadcastTeam {
logoUrl: string | null;
seed: number | null;
players: BroadcastPlayer[];
/** The producer edited the team's or its players' values. */
isEdited: boolean;
}
export interface BroadcastCaster {
userId: number;
name: string;
pronouns: Pronouns | null;
}
export interface BroadcastPlayer {

View File

@@ -1,3 +1,4 @@
import type { Pronouns } from "~/db/tables-json";
import {
BROADCAST_INK_COLORS,
type BROADCAST_SCENE_PARTS,
@@ -74,6 +75,11 @@ export function inkColorVars(
};
}
/** Pronouns as shown next to a name, e.g. "he/him". */
export function pronounsLabel(pronouns: Pronouns) {
return `${pronouns.subject}/${pronouns.object}`;
}
/** Bracket and round, e.g. "Main bracket · Winners round 2". */
export function roundLabel(
match: Pick<BroadcastMatch, "bracketName" | "roundName">,

View File

@@ -12,7 +12,11 @@
--bc-surface-high: var(--color-bg-higher);
--bc-border: var(--color-border);
--bc-text: var(--color-text);
--bc-text-muted: color-mix(in oklch, var(--color-text) 45%, var(--color-text-high));
--bc-text-muted: color-mix(
in oklch,
var(--color-text) 45%,
var(--color-text-high)
);
--bc-accent: var(--color-fill-accent);
--bc-accent-text: var(--color-fg-on-accent);
--bc-team-a: var(--color-chart-alpha);

View File

@@ -68,6 +68,13 @@
white-space: nowrap;
}
.pronouns {
margin-inline-start: 14px;
color: var(--bc-text-muted);
font-size: 26px;
font-weight: 600;
}
.matchup {
--bc-enter-y: var(--bc-enter-distance);

View File

@@ -2,7 +2,7 @@ import type * as React from "react";
import { useTranslation } from "react-i18next";
import { BROADCAST_CANVAS } from "../broadcast-constants";
import type { BroadcastState, BroadcastView } from "../broadcast-types";
import { scenePart } from "../broadcast-utils";
import { pronounsLabel, scenePart } from "../broadcast-utils";
import styles from "./CastersScene.module.css";
import { Matchup } from "./Matchup";
import { SceneBackdrop } from "./SceneBackdrop";
@@ -41,7 +41,7 @@ export function CastersScene({
view: BroadcastView;
}) {
const { t } = useTranslation(["broadcast"]);
const casters = state.settings.casters;
const casters = state.casters;
const frames =
FRAME_LAYOUTS[casters.length] ?? FRAME_LAYOUTS[DEFAULT_FRAME_COUNT];
@@ -75,7 +75,12 @@ export function CastersScene({
>
{casters[i] ? (
<div className={styles.name} {...part("caster-name")}>
{casters[i]}
{casters[i].name}
{casters[i].pronouns ? (
<span className={styles.pronouns} {...part("caster-pronouns")}>
{pronounsLabel(casters[i].pronouns)}
</span>
) : null}
</div>
) : null}
</div>

View File

@@ -7,7 +7,12 @@ import type {
BroadcastState,
BroadcastView,
} from "../broadcast-types";
import { type MatchSide, matchSides, scenePart } from "../broadcast-utils";
import {
type MatchSide,
matchSides,
pronounsLabel,
scenePart,
} from "../broadcast-utils";
import styles from "./GameScene.module.css";
import { SetLength } from "./SetLength";
@@ -89,7 +94,13 @@ function TeamRow({
function InfoBar({ state }: { state: BroadcastState }) {
const match = state.match;
const casters = state.settings.casters.join(" // ");
const casters = state.casters
.map((caster) =>
caster.pronouns
? `${caster.name} (${pronounsLabel(caster.pronouns)})`
: caster.name,
)
.join(" // ");
return (
<div className={styles.infoBar} {...part("info-bar")}>

View File

@@ -6,14 +6,11 @@ import * as TournamentMatchRepository from "~/features/tournament-match/Tourname
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
import { avatarUrl } from "~/utils/urls";
import * as BroadcastRepository from "../BroadcastRepository.server";
import {
DEFAULT_BROADCAST_OVERRIDES,
DEFAULT_BROADCAST_SETTINGS,
} from "../broadcast-constants";
import { DEFAULT_BROADCAST_SETTINGS } from "../broadcast-constants";
import type {
BroadcastCaster,
BroadcastChannel,
BroadcastMatch,
BroadcastOverrides,
BroadcastPlayer,
BroadcastState,
BroadcastTeam,
@@ -22,7 +19,6 @@ import type {
import * as CastChannel from "./CastChannel";
import * as Producer from "./Producer";
import * as SetProgress from "./SetProgress";
import * as TeamOverrides from "./TeamOverrides";
type PlayerRow = Awaited<
ReturnType<typeof BroadcastRepository.findAllPlayersByTournamentTeamIds>
@@ -50,6 +46,8 @@ export async function build({
tournamentId,
channel,
});
// settings added later are missing from broadcasts written before
const settings = { ...DEFAULT_BROADCAST_SETTINGS, ...stored?.settings };
const featuredMatchId = CastChannel.featuredMatchId({
castedMatchesInfo: tournament.ctx.castedMatchesInfo,
channel,
@@ -60,8 +58,8 @@ export async function build({
snapshot: {
v: 1,
...(stored?.state ?? Producer.initialState()),
// settings added later are missing from broadcasts written before
settings: { ...DEFAULT_BROADCAST_SETTINGS, ...stored?.settings },
settings,
casters: await casters(settings.casterIds),
tournament: {
name: tournament.ctx.name,
logoUrl: tournament.ctx.logoUrl,
@@ -70,7 +68,6 @@ export async function build({
? await featuredMatch({
tournament,
matchId: featuredMatchId,
overrides: stored?.overrides ?? DEFAULT_BROADCAST_OVERRIDES,
liveResults: stored?.state.liveResults ?? null,
})
: null,
@@ -78,41 +75,22 @@ export async function build({
};
}
/** The team's editable values as the tournament has them, without the producer's edits. `null` for a team not in the tournament. */
export async function findTeamValues({
tournament,
teamId,
}: {
tournament: Tournament;
teamId: number;
}): Promise<TeamOverrides.TeamValues | null> {
const tournamentTeam = tournament.teamById(teamId);
if (!tournamentTeam) return null;
async function casters(userIds: number[]): Promise<BroadcastCaster[]> {
const users = await BroadcastRepository.findAllCastersByUserIds(userIds);
const players = await BroadcastRepository.findAllPlayersByTournamentTeamIds([
teamId,
]);
return {
name: tournamentTeam.name,
players: rosterPlayers({ players, tournament, tournamentTeam }).map(
(player) => {
const { userId, name, weapons } = toBroadcastPlayer(player);
return { userId, name, weapons };
},
),
};
return userIds.flatMap((userId) => {
const user = users.find((candidate) => candidate.userId === userId);
return user ? [user] : [];
});
}
async function featuredMatch({
tournament,
matchId,
overrides,
liveResults,
}: {
tournament: Tournament;
matchId: number;
overrides: BroadcastOverrides;
liveResults: LiveResults | null;
}): Promise<BroadcastMatch | null> {
const match = await TournamentMatchRepository.findMatchById(matchId);
@@ -153,23 +131,19 @@ async function featuredMatch({
: null;
if (!opponent?.id || !tournamentTeam) return null;
return TeamOverrides.apply(
{
id: tournamentTeam.id,
name: tournamentTeam.name,
logoUrl: tournamentTeam.logoUrl,
seed: tournamentTeam.seed,
players: rosterPlayers({
players: players.filter(
(player) => player.tournamentTeamId === tournamentTeam.id,
),
tournament,
tournamentTeam,
}).map(toBroadcastPlayer),
isEdited: false,
},
overrides,
);
return {
id: tournamentTeam.id,
name: tournamentTeam.name,
logoUrl: tournamentTeam.logoUrl,
seed: tournamentTeam.seed,
players: rosterPlayers({
players: players.filter(
(player) => player.tournamentTeamId === tournamentTeam.id,
),
tournament,
tournamentTeam,
}).map(toBroadcastPlayer),
};
};
const names = tournament.matchContextNamesById(matchId);

View File

@@ -1,107 +0,0 @@
import { describe, expect, test } from "vitest";
import type { BroadcastTeam } from "../broadcast-types";
import * as TeamOverrides from "./TeamOverrides";
const team: BroadcastTeam = {
id: 1,
name: "Team One",
logoUrl: null,
seed: 1,
isEdited: false,
players: [
{
userId: 10,
name: "Ten",
avatarUrl: null,
weapons: [0, 10],
},
{
userId: 11,
name: "Eleven",
avatarUrl: null,
weapons: [20],
},
],
};
const defaults: TeamOverrides.TeamValues = {
name: team.name,
players: team.players.map(({ userId, name, weapons }) => ({
userId,
name,
weapons,
})),
};
describe("TeamOverrides.apply", () => {
test("shows the edited values over the tournament's", () => {
const applied = TeamOverrides.apply(team, {
teams: { 1: { name: "Renamed" } },
players: { 11: { weapons: [40] } },
});
expect(applied.name).toBe("Renamed");
expect(applied.players[0]).toEqual(team.players[0]);
expect(applied.players[1]).toEqual({ ...team.players[1], weapons: [40] });
expect(applied.isEdited).toBe(true);
});
test("ignores overrides of other teams and their players", () => {
const applied = TeamOverrides.apply(team, {
teams: { 2: { name: "Other" } },
players: { 20: { name: "Other player" } },
});
expect(applied).toEqual(team);
});
});
describe("TeamOverrides.fromSubmitted", () => {
test("stores nothing when the submitted values are the tournament's", () => {
expect(
TeamOverrides.fromSubmitted({ defaults, submitted: defaults }),
).toEqual({
team: null,
players: [
{ userId: 10, override: null },
{ userId: 11, override: null },
],
});
});
test("stores only the fields that differ", () => {
const overrides = TeamOverrides.fromSubmitted({
defaults,
submitted: {
name: "Renamed",
players: [
{ userId: 10, name: "Ten", weapons: [10, 0] },
{ userId: 11, name: "Eleventh", weapons: [20] },
],
},
});
expect(overrides).toEqual({
team: { name: "Renamed" },
players: [
{ userId: 10, override: { weapons: [10, 0] } },
{ userId: 11, override: { name: "Eleventh" } },
],
});
});
test("ignores submitted players not on the team", () => {
const overrides = TeamOverrides.fromSubmitted({
defaults,
submitted: {
...defaults,
players: [
...defaults.players,
{ userId: 99, name: "Intruder", weapons: [] },
],
},
});
expect(overrides.players.map((player) => player.userId)).toEqual([10, 11]);
});
});

View File

@@ -1,73 +0,0 @@
import * as R from "remeda";
import type {
BroadcastOverrides,
BroadcastPlayer,
BroadcastPlayerOverride,
BroadcastTeam,
BroadcastTeamOverride,
} from "../broadcast-types";
/** The editable values of a team, as the tournament has them or as the producer submitted them. */
export interface TeamValues {
name: string;
players: Array<Pick<BroadcastPlayer, "userId" | "name" | "weapons">>;
}
/** The team with the producer's edits applied over the tournament's values. */
export function apply(
team: BroadcastTeam,
overrides: BroadcastOverrides,
): BroadcastTeam {
const teamOverride = overrides.teams[team.id];
return {
...team,
...teamOverride,
players: team.players.map((player) => ({
...player,
...overrides.players[player.userId],
})),
isEdited:
Boolean(teamOverride) ||
team.players.some((player) => overrides.players[player.userId]),
};
}
/**
* Overrides to store for the submitted values: only what differs from the tournament's values, `null` where
* nothing does (so later changes to the tournament's values show again). Submitted players not on the team are ignored.
*/
export function fromSubmitted({
defaults,
submitted,
}: {
defaults: TeamValues;
submitted: TeamValues;
}): {
team: BroadcastTeamOverride | null;
players: Array<{ userId: number; override: BroadcastPlayerOverride | null }>;
} {
return {
team: submitted.name !== defaults.name ? { name: submitted.name } : null,
players: defaults.players.map((player) => {
const submittedPlayer = submitted.players.find(
(candidate) => candidate.userId === player.userId,
);
const override: BroadcastPlayerOverride = {};
if (submittedPlayer && submittedPlayer.name !== player.name) {
override.name = submittedPlayer.name;
}
if (
submittedPlayer &&
!R.isDeepEqual(submittedPlayer.weapons, player.weapons)
) {
override.weapons = submittedPlayer.weapons;
}
return {
userId: player.userId,
override: R.isEmpty(override) ? null : override,
};
}),
};
}

View File

@@ -118,12 +118,6 @@
text-transform: uppercase;
}
.teams {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
gap: var(--s-8);
}
.viewHeader {
font-size: var(--font-md);
text-transform: uppercase;

View File

@@ -11,7 +11,6 @@ import {
Map as MapIcon,
Mic,
Play,
RotateCcw,
TriangleAlert,
Trophy,
Users,
@@ -43,13 +42,11 @@ import { useBroadcastStream } from "../broadcast-hooks";
import {
broadcastActionSchema,
broadcastInfoFormSchema,
broadcastTeamFormSchema,
broadcastThemeFormSchema,
} from "../broadcast-schemas";
import type {
BroadcastScene,
BroadcastState,
BroadcastTeam,
BroadcastView,
} from "../broadcast-types";
import { broadcastOverlayPage, broadcastStreamUrl } from "../broadcast-urls";
@@ -175,13 +172,13 @@ export default function BroadcastDashboardPage() {
schema={broadcastInfoFormSchema}
defaultValues={{
headline: state.settings.headline || null,
casters: state.settings.casters,
casterIds: state.settings.casterIds,
}}
>
{({ FormField }) => (
<>
<FormField name="headline" />
<FormField name="casters" />
<FormField name="casterIds" />
</>
)}
</SendouForm>
@@ -367,27 +364,18 @@ function Teams({ state }: { state: BroadcastState }) {
return (
<SendouSection title={t("broadcast:dashboard.teams.header")}>
<div className="stack md">
<div className="stack horizontal md items-center justify-between flex-wrap">
<p className="text-sm text-lighter">
{t("broadcast:dashboard.teams.description")}
</p>
<ActionButton
schema={broadcastActionSchema}
action="SET_NEXT_SWAP_SIDES"
fields={{ swapSides: !state.next.swapSides }}
variant={state.next.swapSides ? "primary" : "outlined"}
size="small"
icon={<ArrowLeftRight />}
>
{t("broadcast:dashboard.swapSides")}
</ActionButton>
</div>
<ActionButton
schema={broadcastActionSchema}
action="SET_NEXT_SWAP_SIDES"
fields={{ swapSides: !state.next.swapSides }}
variant={state.next.swapSides ? "primary" : "outlined"}
size="small"
icon={<ArrowLeftRight />}
formClassName="self-start"
>
{t("broadcast:dashboard.swapSides")}
</ActionButton>
<InkColors state={state} />
<div className={styles.teams}>
{state.match.teams.map((team, i) =>
team ? <TeamEditor key={team.id} team={team} /> : <div key={i} />,
)}
</div>
</div>
</SendouSection>
);
@@ -470,50 +458,6 @@ function InkColors({ state }: { state: BroadcastState }) {
);
}
function TeamEditor({ team }: { team: BroadcastTeam }) {
const { t } = useTranslation(["broadcast"]);
const defaultValues = {
teamId: team.id,
name: team.name,
players: team.players.map((player) => ({
userId: player.userId,
name: player.name,
weapons: player.weapons.map((id) => ({ id, isFavorite: false })),
})),
};
return (
<div className="stack md">
{team.isEdited ? (
<ActionButton
schema={broadcastActionSchema}
action="RESET_TEAM"
fields={{ teamId: team.id }}
variant="minimal"
size="small"
icon={<RotateCcw />}
formClassName="self-start"
>
{t("broadcast:dashboard.teams.reset")}
</ActionButton>
) : null}
<SendouForm
// remounts with the saved values, e.g. after another producer's edit
key={JSON.stringify(defaultValues)}
schema={broadcastTeamFormSchema}
defaultValues={defaultValues}
>
{({ FormField }) => (
<>
<FormField name="name" />
<FormField name="players" canRemoveItem={() => false} />
</>
)}
</SendouForm>
</div>
);
}
function ResultsNotOnAir({ state }: { state: BroadcastState }) {
const { t } = useTranslation(["broadcast"]);

View File

@@ -18,8 +18,6 @@
"dashboard.showResults": "Show now",
"dashboard.gameplay": "Gameplay footage",
"dashboard.teams.header": "Teams",
"dashboard.teams.description": "Edits show on both live and next right away. Values you leave as they are keep following the tournament.",
"dashboard.teams.reset": "Reset to tournament values",
"dashboard.health.sources_one": "OBS: {{count}} source connected",
"dashboard.health.sources_other": "OBS: {{count}} sources connected",
"dashboard.health.disconnected": "Disconnected from sendou.ink, reconnecting...",

View File

@@ -530,8 +530,6 @@
"errors.canNotSetIfStartingNow": "Can not be set if starting now",
"labels.broadcastCss": "Custom CSS",
"bottomTexts.broadcastCss": "Applied after the default styles. Override the variables on [data-broadcast], redefine the bc-* @keyframes or target parts like [data-scene=\"game\"] [data-part=\"team-name\"].",
"labels.broadcastPlayers": "Players",
"labels.broadcastWeapons": "Weapons",
"labels.broadcastStinger": "Stinger",
"bottomTexts.broadcastStinger": "Full screen animation with the upcoming match's info, covering the scene change on Takes that use the stinger transition.",
"options.broadcastStinger.versus": "Versus: the teams clash from both sides",
@@ -541,5 +539,5 @@
"labels.broadcastHeadline": "Headline",
"bottomTexts.broadcastHeadline": "Shown on the full screen scenes, e.g. Starting soon and Break.",
"labels.broadcastCasters": "Casters",
"bottomTexts.broadcastCasters": "Shown as written, e.g. \"Sendou - he/him\". The Game scene lists them next to the round, the Casters scene has a camera frame for each (put your camera sources under the browser source in OBS)."
"bottomTexts.broadcastCasters": "Shown with the pronouns of their profile. The Game scene lists them next to the round, the Casters scene has a camera frame for each (put your camera sources under the browser source in OBS)."
}

View File

@@ -1,6 +1,6 @@
import { type Kysely, sql } from "kysely";
/** Broadcast overlays: one row per cast account of a tournament holding its producer state, theme, edited team values and chat room. */
/** Broadcast overlays: one row per cast account of a tournament holding its producer state, theme and chat room. */
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
@@ -13,7 +13,6 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn("account", "text", (col) => col.notNull())
.addColumn("state", "text", (col) => col.notNull())
.addColumn("settings", "text", (col) => col.notNull())
.addColumn("overrides", "text", (col) => col.notNull())
.addColumn("chatRoomId", "integer", (col) =>
col.references("ChatRoom.id").onDelete("set null"),
)