Compare commits
2
Commits
a73acccf1a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc326a0e3f | ||
|
|
37899da18a |
No files matched your search
@@ -91,6 +91,10 @@ just test
|
||||
just frontend-test
|
||||
```
|
||||
|
||||
Frontend tests render components with the React packages installed by
|
||||
`just setup`. Their separate Deno configuration uses those installed packages
|
||||
without rewriting the application's dependency lockfile.
|
||||
|
||||
See [launcher shutdown ownership](crates/lanspread-tauri-deno-ts/SHUTDOWN.md)
|
||||
for the cancellation/drain sequence and a native close-button regression probe.
|
||||
|
||||
@@ -319,7 +323,7 @@ just peer-cli-charlie
|
||||
| `just fmt` | Format Rust, TOML, Markdown, and the Justfile. |
|
||||
| `just clippy` | Lint every Rust workspace target with warnings denied. |
|
||||
| `just test` | Run every Rust workspace test target. |
|
||||
| `just frontend-test` | Run the frontend reducer/lifecycle tests. |
|
||||
| `just frontend-test` | Run frontend reducer, lifecycle, and rendered component tests. |
|
||||
| `just peer-cli-build` | Build the JSONL peer harness. |
|
||||
| `just peer-cli-image` | Check fixture authority and build the harness Docker image. |
|
||||
| `just peer-cli-tests [SCENARIO ...]` | Run selected scenarios, or the complete matrix when no IDs are supplied. |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"nodeModulesDir": "manual",
|
||||
"lock": false,
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"lib": ["deno.window", "dom", "dom.iterable"]
|
||||
}
|
||||
}
|
||||
@@ -70,10 +70,14 @@ export const NominationCard = ({
|
||||
onLaunch,
|
||||
}: Props) => {
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
const [confirmStart, setConfirmStart] = useState(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (focused) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}, [focused]);
|
||||
useEffect(() => {
|
||||
setConfirmStart(false);
|
||||
}, [nomination.id, nomination.state, actorId]);
|
||||
|
||||
const now = Date.now();
|
||||
const entries = Object.entries(nomination.participants);
|
||||
@@ -212,6 +216,8 @@ export const NominationCard = ({
|
||||
actions={actions}
|
||||
onLaunch={onLaunch}
|
||||
now={now}
|
||||
confirmStart={confirmStart}
|
||||
onStartConfirmationChange={setConfirmStart}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -255,6 +261,8 @@ interface CardActionsProps {
|
||||
actions: CallToPlayActions;
|
||||
onLaunch: (game: Game) => void;
|
||||
now: number;
|
||||
confirmStart: boolean;
|
||||
onStartConfirmationChange: (confirm: boolean) => void;
|
||||
}
|
||||
|
||||
const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
|
||||
@@ -278,13 +286,15 @@ const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
|
||||
</>
|
||||
);
|
||||
|
||||
const CardActions = ({
|
||||
export const CardActions = ({
|
||||
nomination,
|
||||
game,
|
||||
actorId,
|
||||
actions,
|
||||
onLaunch,
|
||||
now,
|
||||
confirmStart,
|
||||
onStartConfirmationChange,
|
||||
}: CardActionsProps) => {
|
||||
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
|
||||
const isMe = myStatus !== undefined;
|
||||
@@ -294,6 +304,11 @@ const CardActions = ({
|
||||
const isExpired = statusOf(nomination, now) === 'expired';
|
||||
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !terminal;
|
||||
const readyCount = readyCountOf(nomination, now);
|
||||
const startCall = async () => {
|
||||
onStartConfirmationChange(false);
|
||||
const accepted = await actions.startNow(nomination.id);
|
||||
if (accepted && game) onLaunch(game);
|
||||
};
|
||||
|
||||
if (terminal) {
|
||||
return (
|
||||
@@ -306,14 +321,57 @@ const CardActions = ({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Readiness and the schedule guide coordination; the caller may start early.
|
||||
if (isCreator) {
|
||||
return (
|
||||
<>
|
||||
{confirmStart && !isDone
|
||||
? (
|
||||
<div className="ctp-start-confirm" role="group" aria-label="Start this call early?">
|
||||
<span>Start this call early?</span>
|
||||
<div className="ctp-start-confirm-btns">
|
||||
<button className="act-btn act-play" onClick={startCall}>
|
||||
<Icon.play /><span>Start now</span>
|
||||
</button>
|
||||
<button
|
||||
className="ghost-btn"
|
||||
onClick={() => onStartConfirmationChange(false)}
|
||||
>Keep waiting</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
className="act-btn act-play"
|
||||
onClick={() => {
|
||||
if (!isDone) {
|
||||
onStartConfirmationChange(true);
|
||||
return;
|
||||
}
|
||||
return startCall();
|
||||
}}
|
||||
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
|
||||
)}
|
||||
{isDone && (
|
||||
<button
|
||||
className="ghost-btn"
|
||||
onClick={() => actions.addTime(nomination.id, nomination.deadline)}
|
||||
>
|
||||
Add 5 more minutes
|
||||
</button>
|
||||
)}
|
||||
{scheduled && (
|
||||
<div className="ctp-note">
|
||||
Scheduled for {formatClock(nomination.scheduledFor!)} — check-in opens 15 min before start.
|
||||
</div>
|
||||
)}
|
||||
{!isDone && !scheduled && myStatus?.status === 'in' && (
|
||||
<ReadyButtons nomination={nomination} actions={actions} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (scheduled) {
|
||||
if (isCreator) {
|
||||
return (
|
||||
<div className="ctp-note">
|
||||
Scheduled for {formatClock(nomination.scheduledFor!)} — check-in opens 15 min before start.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isMe) {
|
||||
return (
|
||||
<>
|
||||
@@ -333,30 +391,6 @@ const CardActions = ({
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (isCreator) {
|
||||
if (isDone) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="act-btn act-play"
|
||||
onClick={() => void actions.startNow(nomination.id).then(accepted => {
|
||||
if (accepted && game) onLaunch(game);
|
||||
})}
|
||||
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
|
||||
<button
|
||||
className="ghost-btn"
|
||||
onClick={() => actions.addTime(nomination.id, nomination.deadline)}
|
||||
>
|
||||
Add 5 more minutes
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (myStatus?.status === 'in') {
|
||||
return <ReadyButtons nomination={nomination} actions={actions} />;
|
||||
}
|
||||
return <div className="ctp-note">Waiting for players to ready up…</div>;
|
||||
}
|
||||
if (isDone) {
|
||||
return (
|
||||
<div className="ctp-note">
|
||||
|
||||
@@ -2201,26 +2201,38 @@
|
||||
.ctp-cancel-link:hover {
|
||||
color: #fca5a5;
|
||||
}
|
||||
.ctp-cancel-confirm {
|
||||
.ctp-cancel-confirm,
|
||||
.ctp-start-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(239,68,68,0.08);
|
||||
border: 1px solid rgba(239,68,68,0.35);
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
color: #fca5a5;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ctp-cancel-confirm-btns {
|
||||
.ctp-cancel-confirm {
|
||||
background: rgba(239,68,68,0.08);
|
||||
border: 1px solid rgba(239,68,68,0.35);
|
||||
color: #fca5a5;
|
||||
}
|
||||
.ctp-start-confirm {
|
||||
flex: 1 1 100%;
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
|
||||
color: var(--t-1);
|
||||
}
|
||||
.ctp-cancel-confirm-btns,
|
||||
.ctp-start-confirm-btns {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ctp-cancel-confirm-btns .ghost-btn {
|
||||
.ctp-cancel-confirm-btns .ghost-btn,
|
||||
.ctp-actions .ctp-start-confirm-btns .act-btn,
|
||||
.ctp-actions .ctp-start-confirm-btns .ghost-btn {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { CardActions, NominationCard } from '../src/components/calltoplay/NominationCard.tsx';
|
||||
import { type CallToPlayActions } from '../src/hooks/useCallToPlay.ts';
|
||||
import { reduceCallToPlayEvents } from '../src/lib/callToPlay.ts';
|
||||
import {
|
||||
type CallToPlayViewEvent,
|
||||
type Game,
|
||||
GameAvailability,
|
||||
InstallStatus,
|
||||
type Nomination,
|
||||
} from '../src/lib/types.ts';
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string): void => {
|
||||
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||
};
|
||||
|
||||
const game: Game = {
|
||||
id: 'game-1',
|
||||
name: 'Test game',
|
||||
description: '',
|
||||
size: 0,
|
||||
downloaded: true,
|
||||
installed: true,
|
||||
availability: GameAvailability.Ready,
|
||||
install_status: InstallStatus.Installed,
|
||||
peer_count: 0,
|
||||
};
|
||||
|
||||
const unexpectedAction = (): never => {
|
||||
throw new Error('Rendering a card must not publish an action or launch a game');
|
||||
};
|
||||
|
||||
const actions: CallToPlayActions = {
|
||||
createNomination: unexpectedAction,
|
||||
respond: unexpectedAction,
|
||||
rsvp: unexpectedAction,
|
||||
sendMessage: unexpectedAction,
|
||||
leave: unexpectedAction,
|
||||
cancel: unexpectedAction,
|
||||
startNow: unexpectedAction,
|
||||
addTime: unexpectedAction,
|
||||
};
|
||||
|
||||
const call = (
|
||||
now: number,
|
||||
players = 1,
|
||||
scheduledFor: number | null = null,
|
||||
deadline = scheduledFor ?? now + 10 * 60_000,
|
||||
): Nomination => {
|
||||
const events: CallToPlayViewEvent[] = [{
|
||||
id: 'create',
|
||||
call_id: 'call-1',
|
||||
author_id: 'Alice',
|
||||
author_name: 'Alice',
|
||||
at: now - 60_000,
|
||||
action: {
|
||||
Create: {
|
||||
game_id: game.id,
|
||||
max_players: 8,
|
||||
scheduled_for: scheduledFor,
|
||||
deadline,
|
||||
},
|
||||
},
|
||||
}];
|
||||
for (let index = 1; index < players; index += 1) {
|
||||
events.push({
|
||||
id: `join-${index}`,
|
||||
call_id: 'call-1',
|
||||
author_id: `Player-${index}`,
|
||||
author_name: `Player ${index}`,
|
||||
at: now - 30_000 + index,
|
||||
action: scheduledFor === null ? { Respond: { ready_at: null } } : 'Rsvp',
|
||||
});
|
||||
}
|
||||
const [nomination] = reduceCallToPlayEvents(events, now);
|
||||
assert(nomination, 'the call must be visible');
|
||||
return nomination;
|
||||
};
|
||||
|
||||
const renderCard = (
|
||||
nomination: Nomination,
|
||||
actorId: string | null = 'Alice',
|
||||
localGame: Game | null = game,
|
||||
): string => renderToStaticMarkup(createElement(NominationCard, {
|
||||
nomination,
|
||||
game: localGame,
|
||||
actorId,
|
||||
actions,
|
||||
focused: false,
|
||||
totalPeerCount: 5,
|
||||
onLaunch: unexpectedAction,
|
||||
}));
|
||||
|
||||
const buttonNamed = (markup: string, label: string): string | undefined =>
|
||||
markup.match(/<button\b[^>]*>.*?<\/button>/gs)?.find(button =>
|
||||
button.replace(/<[^>]*>/g, '').trim() === label
|
||||
);
|
||||
|
||||
const assertStartAvailable = (markup: string, label = 'Start now'): void => {
|
||||
const button = buttonNamed(markup, label);
|
||||
assert(button !== undefined, `${label} must be rendered`);
|
||||
assert(!/\bdisabled\b/.test(button), `${label} must be enabled`);
|
||||
};
|
||||
|
||||
Deno.test('caller can start an eight-player call early with six ready players', () => {
|
||||
const nomination = call(Date.now(), 6);
|
||||
assert(nomination.state === 'open', 'six players must not fill an eight-player call');
|
||||
assertStartAvailable(renderCard(nomination));
|
||||
});
|
||||
|
||||
Deno.test('caller can start alone before the call deadline', () => {
|
||||
assertStartAvailable(renderCard(call(Date.now())));
|
||||
});
|
||||
|
||||
Deno.test('caller can start a scheduled call before check-in opens', () => {
|
||||
const now = Date.now();
|
||||
const markup = renderCard(call(now, 1, now + 60 * 60_000));
|
||||
assertStartAvailable(markup);
|
||||
assert(markup.includes('check-in opens 15 min before start'), 'schedule guidance remains');
|
||||
});
|
||||
|
||||
Deno.test('caller can start during check-in and can still mark themselves ready', () => {
|
||||
const now = Date.now();
|
||||
const markup = renderCard(call(now, 1, now + 10 * 60_000));
|
||||
assertStartAvailable(markup);
|
||||
assert(buttonNamed(markup, 'Ready now'), 'caller check-in controls remain');
|
||||
});
|
||||
|
||||
Deno.test('full and elapsed calls keep start and extension controls', () => {
|
||||
const now = Date.now();
|
||||
for (const nomination of [call(now, 8), call(now, 1, null, now - 1_000)]) {
|
||||
const markup = renderCard(nomination);
|
||||
assertStartAvailable(markup);
|
||||
assert(buttonNamed(markup, 'Add 5 more minutes'), 'extension remains available');
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('participants and outsiders cannot start a call in any active phase', () => {
|
||||
const now = Date.now();
|
||||
const nominations = [
|
||||
call(now, 6),
|
||||
call(now, 6, now + 60 * 60_000),
|
||||
call(now, 6, now + 10 * 60_000),
|
||||
call(now, 8),
|
||||
call(now, 6, null, now - 1_000),
|
||||
];
|
||||
for (const nomination of nominations) {
|
||||
for (const actorId of ['Player-1', 'Outsider', null]) {
|
||||
const markup = renderCard(nomination, actorId);
|
||||
assert(!buttonNamed(markup, 'Start now'), 'only the caller can start');
|
||||
assert(!buttonNamed(markup, 'Add 5 more minutes'), 'only the caller can extend');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('running and cancelled calls cannot be started again', () => {
|
||||
const now = Date.now();
|
||||
for (const state of ['running', 'cancelled'] as const) {
|
||||
const nomination = { ...call(now, 6), state, terminalAt: now };
|
||||
for (const actorId of ['Alice', 'Player-1']) {
|
||||
const markup = renderCard(nomination, actorId);
|
||||
assert(!buttonNamed(markup, 'Start now'), 'terminal calls cannot restart');
|
||||
assert(!buttonNamed(markup, 'Add 5 more minutes'), 'terminal calls cannot extend');
|
||||
assert(!buttonNamed(markup, 'Cancel this call'), 'terminal calls cannot cancel');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('caller can mark an underfilled call running when the game is unavailable locally', () => {
|
||||
assertStartAvailable(renderCard(call(Date.now(), 6), 'Alice', null), 'Mark as running');
|
||||
});
|
||||
|
||||
type ClickHandler = () => void | Promise<void>;
|
||||
|
||||
const findClickHandler = (node: ReactNode, label: string): ClickHandler | undefined => {
|
||||
if (!isValidElement<{ children?: ReactNode; onClick?: ClickHandler }>(node)) return;
|
||||
if (node.type === 'button' && buttonNamed(renderToStaticMarkup(node), label)) {
|
||||
return node.props.onClick;
|
||||
}
|
||||
for (const child of Children.toArray(node.props.children)) {
|
||||
const handler = findClickHandler(child, label);
|
||||
if (handler) return handler;
|
||||
}
|
||||
};
|
||||
|
||||
// CardActions is controlled by NominationCard. Exercise its real button
|
||||
// callbacks and rerender with the resulting confirmation state.
|
||||
const startHarness = (
|
||||
nomination: Nomination,
|
||||
publication: Promise<boolean> = Promise.resolve(true),
|
||||
localGame: Game | null = game,
|
||||
) => {
|
||||
let confirming = false;
|
||||
const published: string[] = [];
|
||||
const launched: Game[] = [];
|
||||
const view = () => CardActions({
|
||||
nomination,
|
||||
game: localGame,
|
||||
actorId: 'Alice',
|
||||
actions: {
|
||||
...actions,
|
||||
startNow: callId => {
|
||||
published.push(callId);
|
||||
return publication;
|
||||
},
|
||||
},
|
||||
now: Date.now(),
|
||||
onLaunch: launchedGame => launched.push(launchedGame),
|
||||
confirmStart: confirming,
|
||||
onStartConfirmationChange: value => { confirming = value; },
|
||||
});
|
||||
return {
|
||||
published,
|
||||
launched,
|
||||
markup: () => renderToStaticMarkup(view()),
|
||||
click: (label: string) => {
|
||||
const handler = findClickHandler(view(), label);
|
||||
assert(handler, `${label} must have a click handler`);
|
||||
return handler();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Deno.test('early start asks for confirmation without publishing or launching', async () => {
|
||||
const now = Date.now();
|
||||
for (const nomination of [
|
||||
call(now, 6),
|
||||
call(now),
|
||||
call(now, 1, now + 60 * 60_000),
|
||||
call(now, 1, now + 10 * 60_000),
|
||||
]) {
|
||||
const harness = startHarness(nomination);
|
||||
await harness.click('Start now');
|
||||
assert(harness.markup().includes('Start this call early?'), 'early-start question appears');
|
||||
assert(buttonNamed(harness.markup(), 'Keep waiting'), 'waiting remains an explicit choice');
|
||||
assert(harness.published.length === 0, 'first click must not publish Start');
|
||||
assert(harness.launched.length === 0, 'first click must not launch');
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('keeping a call waiting dismisses confirmation and requires it again next time', async () => {
|
||||
const harness = startHarness(call(Date.now(), 6));
|
||||
await harness.click('Start now');
|
||||
await harness.click('Keep waiting');
|
||||
assert(!harness.markup().includes('Start this call early?'), 'confirmation closes');
|
||||
assertStartAvailable(harness.markup());
|
||||
await harness.click('Start now');
|
||||
assert(harness.markup().includes('Start this call early?'), 'a later early start asks again');
|
||||
assert(harness.published.length === 0, 'keeping the call waiting publishes nothing');
|
||||
assert(harness.launched.length === 0, 'keeping the call waiting launches nothing');
|
||||
});
|
||||
|
||||
Deno.test('confirming an early start launches only after core acceptance', async () => {
|
||||
let accept!: (accepted: boolean) => void;
|
||||
const publication = new Promise<boolean>(resolve => { accept = resolve; });
|
||||
const nomination = call(Date.now(), 6);
|
||||
const harness = startHarness(nomination, publication);
|
||||
await harness.click('Start now');
|
||||
const confirmation = harness.click('Start now');
|
||||
assert(harness.published.length === 1, 'confirmation publishes exactly one Start');
|
||||
assert(harness.published[0] === nomination.id, 'Start targets this call');
|
||||
assertEquals(harness.launched.length, 0, 'launch waits for core acceptance');
|
||||
accept(true);
|
||||
await confirmation;
|
||||
assert(harness.launched.at(0) === game, 'accepted Start launches the local game');
|
||||
assert(harness.launched.length === 1, 'the game launches exactly once');
|
||||
});
|
||||
|
||||
Deno.test('rejected early starts never launch and require confirmation on retry', async () => {
|
||||
const harness = startHarness(call(Date.now(), 6), Promise.resolve(false));
|
||||
await harness.click('Start now');
|
||||
await harness.click('Start now');
|
||||
assert(harness.published.length === 1, 'confirmation reaches the peer');
|
||||
assert(harness.launched.length === 0, 'rejection cannot launch the game');
|
||||
await harness.click('Start now');
|
||||
assert(harness.markup().includes('Start this call early?'), 'retry requires confirmation');
|
||||
assert(harness.published.length === 1, 'retry first click does not publish');
|
||||
});
|
||||
|
||||
Deno.test('ready and elapsed calls still start with one click', async () => {
|
||||
const now = Date.now();
|
||||
for (const nomination of [call(now, 8), call(now, 1, null, now - 1_000)]) {
|
||||
const harness = startHarness(nomination);
|
||||
await harness.click('Start now');
|
||||
assert(harness.published.length === 1, 'normal start publishes on the first click');
|
||||
assert(harness.launched.length === 1, 'accepted normal start launches');
|
||||
assert(!harness.markup().includes('Start this call early?'), 'normal start needs no confirmation');
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('confirmed early start without a local game only marks the call running', async () => {
|
||||
const harness = startHarness(call(Date.now(), 6), Promise.resolve(true), null);
|
||||
await harness.click('Mark as running');
|
||||
assertEquals(harness.published.length, 0, 'marking early also requires confirmation');
|
||||
await harness.click('Start now');
|
||||
assert(harness.published.length === 1, 'confirmation publishes Start');
|
||||
assert(harness.launched.length === 0, 'an unavailable game cannot launch locally');
|
||||
});
|
||||
+15
-3
@@ -818,7 +818,7 @@ Source: `calltoplay.jsx` (the feature) + `ctp-chat.jsx` (per-call chat + shared
|
||||
| Set up with | game + max players + **duration** (5/10/15/30/60 min) | game + max players + **clock time** (24h, + which day) |
|
||||
| Others respond | `Ready now` or `+N minutes` | `I'm in` (RSVP), then check in later |
|
||||
| Resolves | when the roster fills **or** the timer runs out | at the scheduled time, after a check-in window |
|
||||
| Who starts it | the **caller** decides the actual launch | the **caller**, once people have checked in |
|
||||
| Who starts it | the **caller** decides the actual launch | the **caller** decides, including before check-in |
|
||||
|
||||
**Check-in window.** `CHECKIN_LEAD_MS = 15 min`. A scheduled call sits in the
|
||||
`scheduled` phase collecting RSVPs until 15 minutes before its start time, then
|
||||
@@ -830,6 +830,17 @@ When the deadline passes, the call is labeled **Time's up** rather than Ready.
|
||||
It remains visible for five minutes so the caller can start or extend it, then
|
||||
the call and its history expire as a unit.
|
||||
|
||||
The caller can use **Start now** at any time while the call is visible and has
|
||||
not been started or cancelled, including before scheduled check-in. The player
|
||||
count and deadline guide coordination; they do not gate starting. An
|
||||
eight-player call can start with six ready players, or even the caller alone.
|
||||
Before the roster is ready or the deadline has elapsed, clicking **Start now**
|
||||
opens a small inline confirmation: **Start this call early?**, with **Start
|
||||
now** and **Keep waiting** buttons. Keeping the call waiting publishes no
|
||||
action. Ready and Time's-up calls start with one click. Starting marks the call
|
||||
Running and then invokes the caller's local game action only after the peer
|
||||
accepts it.
|
||||
|
||||
**Every call carries a small group chat** (see "Per-call chat" below).
|
||||
|
||||
### Three surfaces
|
||||
@@ -892,8 +903,9 @@ Top to bottom:
|
||||
avatar chips for each participant plus empty slots up to `maxPlayers`.
|
||||
5. **Actions** — context-dependent on your role (**creator** / **participant** /
|
||||
**outsider**) and phase: `Ready now` + `+5/10/15/30m` buffer buttons,
|
||||
`I'm in` (RSVP), `Start now` / `Add 5 more minutes` (creator once resolved),
|
||||
`Leave` / `Can't make it`, or a status note.
|
||||
`I'm in` (RSVP), `Start now` (creator at any time before Start/Cancel),
|
||||
`Add 5 more minutes` (creator once resolved), `Leave` / `Can't make it`, or a
|
||||
status note.
|
||||
6. **Chat** — the collapsible per-call chat panel.
|
||||
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ catalog-generate-production-game PACKAGES_DIR GAME_ID:
|
||||
--game-id "$GAME_ID"
|
||||
|
||||
frontend-test:
|
||||
cd crates/lanspread-tauri-deno-ts && deno test --unstable-sloppy-imports tests
|
||||
cd crates/lanspread-tauri-deno-ts && deno test --config deno.test.json --allow-env=NODE_ENV --unstable-sloppy-imports tests
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
Reference in new issue
Block a user