Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ src/
swuTwinSunsGrid.tsx Twin Suns seating grid on SWU setup (see swuSetupScreenView.tsx)
swuTwinSunsGame.tsx Twin Suns game container (App renders it when setup passes the seats): players are your base (`Top left`) then each filled seat in `TWIN_SUNS_SEATS` order; holds a `TwinSunsState` from `utils/twinSuns.ts` (the game starts at round 1 on mount: no Start step), a `useGameHistory` log whose snapshots are that state (every action, Undo and **Undo the damage** restore the one before), `picking` (the counter waiting for a base) and `confirmEnd`. A card tap settles a waiting destroyed base (that base heals 5), claims the counter being picked, or adds 1 damage. Analytics as the 1-player game, play mode `twin-suns`; **Finish game** logs "Game won/lost/drawn" from your side (`yourResult`) and sends `onGameEnd`. In a `LandscapeScreen`, turned a quarter in portrait. Rematch resets the state and log with the same players
swuTwinSunsGameView.tsx Twin Suns game view: 4 players, a column of two cells each side of a full-height bar; 3 players, two rows, the bar between the pair on one side and the single cell centred (`twin-suns-row-top` / `-bottom`). Each cell is a `<section>` named by its seat, turned 180° in the top row, sizing the card from the cell with `cq` units; the card is `BaseCard` (`swuGamePieces.tsx`) driven by `useCardDrag` (flipped on top), with the counters' tokens down its left edge (Initiative a button that starts moving it; Plan and Blast `role="img"`), YOU on yours, the round epic action button or the **Epic action used** plate (tap to undo) over the text box, "+5 healed", the **Destroyed** cover and, once over, the place cover. The bar (`navigation` "Game, facing you" / "Game over, facing you") is turned to face you: Back, Log, More, the counters (Initiative's claim slot always, Plan and Blast disabled once claimed), Round or, in the last phase, the note and **Finish game**, End game; in its place while choosing, the counter's prompt and Cancel (at the opening, while nobody has the initiative, **Who has the initiative?** with no Cancel), the destroyed base's question with **No one** and **Undo the damage**, End game's confirmation, or the result list with New setup and Rematch. On a phone Back, Log, More and the counters are 60px squares, each icon centred in a 26px box; on a tablet (`(min-width: 600px) and (min-height: 600px)`: the short side, so a phone held in portrait stays a phone) every bar control is a 94px square with a 36px icon box, one column in 4 players and two in 3. Exports `Seat`, `TwinSunsPlayer`
swuSetupScreenView.tsx SWU setup view, built from `shared/controls.tsx` like X-Wing setup — top bar: Back and More `IconButton`s, the dmgCtrl mark (30px) and the "Star Wars: Unlimited" h1 (22px; 17px on one line in portrait); More opens a `Sheet` with Help and Settings, each closing the sheet first. **Your base** card (`base-card`): the Favourite toggle (star and word, `aria-pressed`), the base art (`ImagePreview`, by height and centred in landscape, by width in portrait), then the aspect icon, name and "SET · HP" beside **Change base**; with no base, one large "Choose your base" button (and the card data's error, if any). In 2 players, a **Their base** card (`their-base-card`, label in `--color-opponent`) built by the same function: no Favourite toggle, "Same as last game" in its header while `theirBaseFromLastGame`, "Choose their base" when empty, opening a second `SwuBaseSheet` titled "Choose their base" with the same bases, favourites and recent bases (your recents: their base never enters them). In Twin Suns, `SwuTwinSunsGrid` (`swuTwinSunsGrid.tsx`, a `role="group"` "Bases") takes the cards' place: a 2×2 grid, your base top left (`Your base: <name>`, accent border, with a separate Favourite star button over its corner; "Choose your base" when empty) and the three `TWIN_SUNS_SEATS` in reading order, each `Opponent, <seat>: <name>` or a dashed `Add base, <seat>`; a base cell shows its art (darkened), aspect icon, name (two lines at most) and "SET · HP". Tapping your base opens your base sheet; tapping a seat opens a third `SwuBaseSheet` titled "Opponent’s base" for that seat: no favourites, `otherPlayersBases` as "Used for other players before", and **Remove base** in the header when the seat has a base. A line under Match counts the players or says what is missing (`twinSunsHint`). Mode `SegmentedControl` (`SWU_MODES`: 1 player / 2 players / Twin Suns) above Match `SegmentedControl` (Casual/Tournament, always shown); primary **Go to game** (Casual), **Start tournament** (Tournament) or **Go to tournament** (a tournament under way), disabled until `ready` (your base, and theirs in 2 players, or two opponents' in Twin Suns), with "Choose their base to start." under it while only theirs is missing. **Start tournament** opens the start sheet (`Sheet`, "Start a tournament"), whose body mounts only while open: your base's name, or in its place the legality error (`formatValidationError`, `role="alert"`) with **Change base** (opens the base sheet); **Format** `SegmentedControl` (Premier / Eternal / Limited, `TOURNAMENT_FORMATS`, the remembered one chosen; changing it is saved); **Match** (Best of 1 / Best of 3, Best of 3 each time); a Rounds `Stepper` (2–16, 5 each time); "Fixed once round 1 starts.", plus "In Limited you can change base between games of a match." with Limited; **Cancel** and **Start round 1** (disabled while the base isn't legal), which closes the sheet and calls `onStartTournament({ format, playMode, totalRounds })`. Fits 390px high. The base sheet is `SwuBaseSheet`. **Landscape**: the card (two side by side in 2 players, Change base then under the name; the grid in Twin Suns) beside the controls column (Mode, Match, the Twin Suns line, primary at the foot; 284px in every mode), no scrolling at 844×390. **Portrait**: top bar, card (in 2 players both cards, sharing the height, art by height; the grid in Twin Suns), Mode, Match, the Twin Suns line, primary at the foot, no scrolling at 390×844. Re-exports `SwudbImport`
swuSetupScreenView.tsx SWU setup view, built from `shared/controls.tsx` like X-Wing setup — top bar: Back and More `IconButton`s, the dmgCtrl mark (30px) and the "Star Wars: Unlimited" h1 (22px; 17px on one line in portrait); More opens a `Sheet` with Help and Settings, each closing the sheet first. **Your base** card (`base-card`): the Favourite toggle (star and word, `aria-pressed`), the base art (`ImagePreview`, by height and centred in landscape, by width in portrait), then the aspect icon, name and "SET · HP" beside **Change base**; with no base, one large "Choose your base" button (and the card data's error, if any). In 2 players, a **Their base** card (`their-base-card`, label in `--color-opponent`) built by the same function: no Favourite toggle, "Same as last game" in its header while `theirBaseFromLastGame`, "Choose their base" when empty, opening a second `SwuBaseSheet` titled "Choose their base" with the same bases, favourites and recent bases (your recents: their base never enters them). In Twin Suns, `SwuTwinSunsGrid` (`swuTwinSunsGrid.tsx`, a `role="group"` "Bases") takes the cards' place: a 2×2 grid, your base top left (`Your base: <name>`, accent border, with a separate Favourite star button over its corner; "Choose your base" when empty) and the three `TWIN_SUNS_SEATS` in reading order, each `Opponent, <seat>: <name>` or a dashed `Add base, <seat>`; a base cell shows its label, then the whole card fitted between label and name (`object-fit: contain`, no crop behind the text), then its aspect icon, name (two lines at most) and "SET · HP". Tapping your base opens your base sheet; tapping a seat opens a third `SwuBaseSheet` titled "Opponent’s base" for that seat: no favourites, `otherPlayersBases` as "Used for other players before", and **Remove base** in the header when the seat has a base. A line under Match counts the players or says what is missing (`twinSunsHint`). Mode `SegmentedControl` (`SWU_MODES`: 1 player / 2 players / Twin Suns) above Match `SegmentedControl` (Casual/Tournament, always shown); primary **Go to game** (Casual), **Start tournament** (Tournament) or **Go to tournament** (a tournament under way), disabled until `ready` (your base, and theirs in 2 players, or two opponents' in Twin Suns), with "Choose their base to start." under it while only theirs is missing. **Start tournament** opens the start sheet (`Sheet`, "Start a tournament"), whose body mounts only while open: your base's name, or in its place the legality error (`formatValidationError`, `role="alert"`) with **Change base** (opens the base sheet); **Format** `SegmentedControl` (Premier / Eternal / Limited, `TOURNAMENT_FORMATS`, the remembered one chosen; changing it is saved); **Match** (Best of 1 / Best of 3, Best of 3 each time); a Rounds `Stepper` (2–16, 5 each time); "Fixed once round 1 starts.", plus "In Limited you can change base between games of a match." with Limited; **Cancel** and **Start round 1** (disabled while the base isn't legal), which closes the sheet and calls `onStartTournament({ format, playMode, totalRounds })`. Fits 390px high. The base sheet is `SwuBaseSheet`. **Landscape**: the card (two side by side in 2 players, Change base then under the name; the grid in Twin Suns) beside the controls column (Mode, Match, the Twin Suns line, primary at the foot; 284px in every mode), no scrolling at 844×390. **Portrait**: top bar, card (in 2 players both cards, sharing the height, each with its art by height beside a column (40%) of the name, on two lines at most, and Change base; the grid in Twin Suns), Mode, Match, the Twin Suns line, primary at the foot, no scrolling at 390×844. Re-exports `SwudbImport`
settingsScreen.tsx Settings screen container — reads and writes useUserSettings (`startScreen`, `useHyperspace`, `forceTokenDisplay`, `meleePlayerGuid`, `enableDamageButtons`, `enableInitiativeBar`, `enableXwingPhases`, `enableXwingAlwaysIncDec`) and reports each change but the Melee ID with `onSettingChanged(key, value)`; passes the last game played (`readLastGame()`) to the view; props `onBack`, `onManageXwingLists`
settingsScreenView.tsx Settings screen view — one page from `shared/controls.tsx`, no tabs, no Help: Back `IconButton` and "Settings" h1; three `<section>`s named General, Star Wars: Unlimited and X-Wing. General: Start screen `SelectButton` opening a "Start screen" `Sheet` of `OptionRow`s (Last game played, with a line saying which setup it opens now from the `lastGame` prop, Game select, Star Wars: Unlimited, X-Wing; choosing closes it). SWU: Hyperspace art, Force tokens `SegmentedControl` (Always / LOF bases / Off), Melee player ID input, Damage −/+ buttons, Initiative. X-Wing: Phase tracker, Score −/+ buttons, Squad lists `Button`. Switches are a local `SwitchRow` (`role="switch"`, `aria-checked`). Each section sets `--color-surface` to the raised colour for its controls, which sit on the section's fill. Landscape: a `5fr 6fr` grid, General and X-Wing left (`settings-left`), SWU right (`settings-right`), labelled controls beside their label at up to 280px; portrait: stacked, labelled controls under their label, and a line under each X-Wing switch
swuTournamentScreen.tsx Tournament screen container, between rounds (rendered only while a tournament exists; it is started from the setup start sheet). `canChangeBase` (Limited + bo3 + matchInProgress + hasPlayedGameInCurrentMatch + !isComplete) gates **Change base**, whose options are the card data's bases of the registered base's set (`useBases`); `candidateBase` is passed to `onGoToGame` when chosen, omitted otherwise (App falls back to tournamentCurrentBase for the game screen); a new match calls `startMatch` and plays the registered base; Drop fires `onTournamentDropped`, Finish `onTournamentEnded`, each then `dropTournament` and `onDrop`; Player portal opens melee.gg with a player ID, otherwise Settings
Expand Down
21 changes: 11 additions & 10 deletions src/components/swuSetupScreenView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,13 +214,14 @@ export default function SwuSetupScreenView({
)

// Under the art rather than over it: the art is the whole card, with its own text.
const baseInfo = (b: Base) => {
// Beside the art the column is narrow, so the name may take two lines rather than one.
const baseInfo = (b: Base, narrow: boolean) => {
const aspect = baseAspect(b)
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 }}>
{aspect && <img src={aspectIconSrc(aspect)} alt="" style={{ width: '28px', height: '28px', flexShrink: 0 }} />}
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 }}>
<span style={{ fontSize: '1.1rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{b.name}</span>
<span style={{ fontSize: '1.1rem', overflow: 'hidden', ...(narrow ? { lineHeight: 1.2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' } : { textOverflow: 'ellipsis', whiteSpace: 'nowrap' }) }}>{b.name}</span>
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-body)' }}>{baseMeta(b)}</span>
</div>
</div>
Expand Down Expand Up @@ -252,10 +253,10 @@ export default function SwuSetupScreenView({
</button>
)

// In 2 players the cards share the space: in portrait each takes a share of the height, and in
// landscape Change base goes under the name, as the card is too narrow for both on one line.
// In 2 players the cards share the space, and Change base goes under the name, as there is no room
// for both on one line: in portrait each card takes a share of the height, with the art beside the
// name so it can take the card's full height, and in landscape the art sits above them.
const shareHeight = isPortrait && twoPlayers
const stackActions = !isPortrait && twoPlayers

const baseCardFor = ({ testId, who, b, baseArt, labelColor, headerExtra, onOpen, alert }: {
testId: string
Expand All @@ -273,8 +274,8 @@ export default function SwuSetupScreenView({
{b && headerExtra}
</div>
{b ? (
<>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<div data-testid="base-body" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: shareHeight ? 'row' : 'column', gap: shareHeight ? '12px' : '10px' }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<ImagePreview
base={b}
src={baseArt.src}
Expand All @@ -289,11 +290,11 @@ export default function SwuSetupScreenView({
onError={baseArt.onError}
/>
</div>
<div style={{ display: 'flex', flexDirection: stackActions ? 'column' : 'row', alignItems: stackActions ? 'stretch' : 'center', gap: stackActions ? '8px' : '12px' }}>
{baseInfo(b)}
<div style={{ display: 'flex', flexDirection: twoPlayers ? 'column' : 'row', alignItems: twoPlayers ? 'stretch' : 'center', gap: twoPlayers ? '8px' : '12px', ...(shareHeight ? { width: '40%', flexShrink: 0 } : {}) }}>
{baseInfo(b, shareHeight)}
<Button onClick={onOpen} style={{ flexShrink: 0 }}>Change base</Button>
</div>
</>
</div>
) : (
<>
{alert}
Expand Down
12 changes: 7 additions & 5 deletions src/components/swuTwinSunsGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,20 @@ function BaseCell({ b, label, labelColor, ariaLabel, border, compact, onClick }:
onClick: () => void
}) {
const aspect = baseAspect(b)
// The base's art behind its name, darkened at the head and foot so the label and name read over the
// card's own text; the plain surface shows through when the art can't load.
// The whole card, fitted between the label and the name: cropped to the cell, its own title bar and
// rules text would show behind them.
const art = b.frontArtLowRes ?? b.frontArt
const background = `linear-gradient(rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.35) 40%, rgba(0, 0, 0, 0.9)),${art ? `url("${art}") center / cover no-repeat, ` : ''}var(--color-surface)`
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
style={{ ...cellStyle, width: '100%', height: '100%', border, background, color: 'var(--color-text-primary)', padding: '10px', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', textAlign: 'left' }}
style={{ ...cellStyle, width: '100%', height: '100%', border, background: 'var(--color-surface)', color: 'var(--color-text-primary)', padding: '10px', display: 'flex', flexDirection: 'column', gap: '6px', textAlign: 'left' }}
>
<span style={labelStyle(labelColor)}>{label}</span>
<span style={{ ...labelStyle(labelColor), lineHeight: '20px' }}>{label}</span>
<span style={{ flex: 1, minHeight: 0, display: 'flex' }}>
{art && <img data-card src={art} alt="" style={{ width: '100%', height: '100%', minHeight: 0, objectFit: 'contain' }} />}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
{aspect && <img src={aspectIconSrc(aspect)} alt="" style={{ width: '24px', height: '24px', flexShrink: 0 }} />}
<span style={{ display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 }}>
Expand Down
42 changes: 42 additions & 0 deletions src/test/swuSetupScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,31 @@ describe('SwuSetupScreen: their base', () => {
expect(screen.getByRole('group', { name: 'Mode' })).toBeInTheDocument()
expect(primary('Go to game')).toBeEnabled()
})

// The two cards share the height, so each card's art sits beside its name and Change base rather than
// over them: the art takes the card's full height, larger than it could be above a row of its own.
it('in portrait each card puts its art beside the name and Change base', () => {
makeMatchMediaMock(true)
keep2p(jediTemple, { theirBaseKey: key(catacombs) })
renderSetup()
for (const [card, name] of [[baseCard(), 'Jedi Temple'], [theirCard(), 'Catacombs of Cadera']] as const) {
const body = within(card).getByTestId('base-body')
expect(body).toHaveStyle({ flexDirection: 'row' })
expect(within(body).getByText(name)).toBeInTheDocument()
expect(within(body).getByRole('button', { name: 'Change base' })).toBeInTheDocument()
}
})

it('in landscape, and with 1 player, the art stays above the name', () => {
keep2p(jediTemple, { theirBaseKey: key(catacombs) })
const { unmount } = renderSetup()
expect(within(baseCard()).getByTestId('base-body')).toHaveStyle({ flexDirection: 'column' })
unmount()
makeMatchMediaMock(true)
keep(jediTemple)
renderSetup()
expect(within(baseCard()).getByTestId('base-body')).toHaveStyle({ flexDirection: 'column' })
})
})

describe('SwuSetupScreen: Twin Suns', () => {
Expand Down Expand Up @@ -991,6 +1016,23 @@ describe('SwuSetupScreen: Twin Suns', () => {
expect(within(grid()).getAllByText('Add base')).toHaveLength(3)
})

// The whole card, fitted inside the tile, with the label above it and the name below: no crop of
// the card's own title bar or rules text shows behind them.
it('each base shows its whole card fitted between its label and its name, not cropped behind them', () => {
keepTwinSuns(jediTemple, [catacombs, null, null])
renderSetup()
for (const [name, b] of [['Your base: Jedi Temple', jediTemple], ['Opponent, top right: Catacombs of Cadera', catacombs]] as const) {
const tile = seatButton(name)
expect(tile.style.background).not.toMatch(/url\(/)
const card = tile.querySelector('img[data-card]') as HTMLImageElement
expect(card.src).toBe(b.frontArt)
expect(card).toHaveStyle({ objectFit: 'contain' })
const parts = [...tile.children]
expect(parts.indexOf(within(tile).getByText(name.startsWith('Your') ? 'Your base' : 'Opponent'))).toBeLessThan(parts.findIndex(p => p.contains(card)))
expect(parts.findIndex(p => p.contains(card))).toBeLessThan(parts.findIndex(p => p.contains(within(tile).getByText(b.name))))
}
})

it('with only your base, Go to game is disabled and the hint says what Twin Suns needs', () => {
keepTwinSuns(jediTemple)
renderSetup()
Expand Down
Loading