game room voice: light the burger voice btn for seated gamers across ROLE/SIG/SKY_SELECT; keep POST's composer inline (OK beside the input) — TDD
Voice (Phase C, room path) — the my_sea voice mesh now extends to the epic game room. room_view sets voice_active for a SEATED gamer (a TableSeat with their gamer) while table_status is in {ROLE_SELECT, SIG_SELECT, SKY_SELECT} — from ROLE_SELECT onset (all tokens committed, seats pre-created by pick_roles) continuously through SKY_SELECT, which hosts the in-page DRAW SEA felt; dark at IN_GAME. voice_room_id = the room UUID (the WebRTC mesh key), voice_muted_at = the persisted mute so an in-arc nav/refresh rejoins muted (mirrors my_sea). The burger fan's shared #id_voice_btn lights .active + carries data-room-id; room.html now includes voice-glow.js (the glow/pulse machine, coexisting w. the sea-btn glow handoff).
No consumer/JS change needed: RoomVoiceConsumer._can_join already gates an epic room on TableSeat membership, the ws/voice/<str:room_id> route serves both mysea-… + bare-UUID keys, and burger-btn.js/voice-mesh.js read data-room-id generically. TDD: RoomVoiceActivationTest (9 ITs — active across the arc, dark at IN_GAME, dark for a non-seated viewer, room-id = UUID, muted-at passthrough) + RoomVoiceConsumerEpicGateTest (2 channels ITs — seated gamer admitted + receives welcome; non-seated refused). 390 epic-view + 11 voice channels ITs green.
- _room.scss: POST composer kept as a flex row — the OK btn sits inline beside the 'Enter a post line' input (the felt/pill chrome stays salvaged in YARN, but the input<->OK row layout is retained; follow-up to 577ef30).
- bundled (parallel work): _room.scss reelhouse h2 font --priUser -> --secUser (SCROLL/POST/PULSE); rootvars.scss chroma-hue primaries brightened (yellow/lime/cyan/indigo/violet/fuschia/magenta).
[[project-my-sea-invite-voice-blueprint]] [[project-character-creation-spec]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4564,3 +4564,80 @@ class CarteSeatSwitchSkySeaTest(TestCase):
|
|||||||
content = self.client.get(self.room_url + "?seat=3").content.decode()
|
content = self.client.get(self.room_url + "?seat=3").content.decode()
|
||||||
self.assertIn("/sea/save?seat=3", content)
|
self.assertIn("/sea/save?seat=3", content)
|
||||||
self.assertIn("/sky/save?seat=3", content)
|
self.assertIn("/sky/save?seat=3", content)
|
||||||
|
|
||||||
|
|
||||||
|
class RoomVoiceActivationTest(TestCase):
|
||||||
|
"""Voice activation for the epic game room. Voice is live for a SEATED
|
||||||
|
gamer across the whole character-creation arc — from ROLE_SELECT onset
|
||||||
|
(all tokens committed, seats pre-created) continuously through SKY_SELECT
|
||||||
|
(which hosts the in-page DRAW SEA felt), turning off at IN_GAME. The
|
||||||
|
burger fan's #id_voice_btn (shared `_burger.html`) lights `.active` +
|
||||||
|
carries `data-room-id` = the room UUID, which the already-room-aware
|
||||||
|
RoomVoiceConsumer (`TableSeat` membership gate) + voice-mesh.js consume."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.founder = User.objects.create(email="vfounder@test.io")
|
||||||
|
self.room = Room.objects.create(name="Voice Room", owner=self.founder)
|
||||||
|
self.gamers = [self.founder]
|
||||||
|
for i in range(2, 7):
|
||||||
|
self.gamers.append(User.objects.create(email=f"vg{i}@test.io"))
|
||||||
|
for i, gamer in enumerate(self.gamers, start=1):
|
||||||
|
slot = self.room.gate_slots.get(slot_number=i)
|
||||||
|
slot.gamer = gamer
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
self.room.gate_status = Room.OPEN
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
for i, gamer in enumerate(self.gamers, start=1):
|
||||||
|
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
|
||||||
|
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||||||
|
|
||||||
|
def _ctx(self, gamer):
|
||||||
|
self.client.force_login(gamer)
|
||||||
|
return self.client.get(self.url).context
|
||||||
|
|
||||||
|
def test_voice_active_for_seated_gamer_in_role_select(self):
|
||||||
|
self.assertIs(self._ctx(self.founder)["voice_active"], True)
|
||||||
|
|
||||||
|
def test_voice_room_id_is_room_uuid(self):
|
||||||
|
self.assertEqual(self._ctx(self.founder)["voice_room_id"], str(self.room.id))
|
||||||
|
|
||||||
|
def test_voice_active_persists_through_sig_select(self):
|
||||||
|
self.room.table_status = Room.SIG_SELECT
|
||||||
|
self.room.save()
|
||||||
|
self.assertIs(self._ctx(self.founder)["voice_active"], True)
|
||||||
|
|
||||||
|
def test_voice_active_persists_through_sky_select(self):
|
||||||
|
# SKY_SELECT hosts the in-page DRAW SEA felt — voice spans it too.
|
||||||
|
self.room.table_status = Room.SKY_SELECT
|
||||||
|
self.room.save()
|
||||||
|
self.assertIs(self._ctx(self.founder)["voice_active"], True)
|
||||||
|
|
||||||
|
def test_voice_inactive_in_game(self):
|
||||||
|
self.room.table_status = Room.IN_GAME
|
||||||
|
self.room.save()
|
||||||
|
self.assertIs(self._ctx(self.founder)["voice_active"], False)
|
||||||
|
|
||||||
|
def test_voice_inactive_for_non_seated_viewer(self):
|
||||||
|
outsider = User.objects.create(email="outsider@test.io")
|
||||||
|
self.assertIs(self._ctx(outsider)["voice_active"], False)
|
||||||
|
|
||||||
|
def test_voice_muted_at_empty_when_unset(self):
|
||||||
|
self.assertEqual(self._ctx(self.founder)["voice_muted_at"], "")
|
||||||
|
|
||||||
|
def test_voice_muted_at_passed_when_set(self):
|
||||||
|
self.founder.voice_muted_at = timezone.now()
|
||||||
|
self.founder.save()
|
||||||
|
ctx = self._ctx(self.founder)
|
||||||
|
self.assertEqual(ctx["voice_muted_at"], self.founder.voice_muted_at.isoformat())
|
||||||
|
|
||||||
|
def test_voice_btn_renders_active_with_room_id(self):
|
||||||
|
# The burger fan's voice sub-btn lights .active + carries the room UUID
|
||||||
|
# (the WebRTC mesh key). NB: .room-page already prints data-room-id, so
|
||||||
|
# assert the voice-btn's own active markup, not the bare attribute.
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
content = self.client.get(self.url).content.decode()
|
||||||
|
self.assertIn('class="burger-fan-btn active" aria-label="Voice"', content)
|
||||||
|
self.assertIn(
|
||||||
|
f'aria-label="Voice" data-room-id="{self.room.id}"', content)
|
||||||
|
|||||||
@@ -800,6 +800,23 @@ def room_view(request, room_id):
|
|||||||
seen_seated.add(seat.gamer_id)
|
seen_seated.add(seat.gamer_id)
|
||||||
seated_others.append(seat.gamer)
|
seated_others.append(seat.gamer)
|
||||||
ctx["seated_others"] = seated_others
|
ctx["seated_others"] = seated_others
|
||||||
|
# Voice (Phase C, room path) — the burger fan's #id_voice_btn lights for a
|
||||||
|
# SEATED gamer across the whole character-creation arc: ROLE_SELECT onset
|
||||||
|
# (all tokens committed, seats pre-created by pick_roles) continuously through
|
||||||
|
# SKY_SELECT (which hosts the in-page DRAW SEA felt), going dark at IN_GAME.
|
||||||
|
# The room UUID is the WebRTC mesh key — RoomVoiceConsumer._can_join already
|
||||||
|
# gates an epic room on TableSeat membership (no consumer change), and
|
||||||
|
# voice-mesh.js / burger-btn.js bind the active click → join/toggle-mute.
|
||||||
|
# voice_muted_at carries the persisted mute so an in-arc nav/refresh rejoins
|
||||||
|
# MUTED (mirrors my_sea). [[project-my-sea-invite-voice-blueprint]]
|
||||||
|
VOICE_PHASES = {Room.ROLE_SELECT, Room.SIG_SELECT, Room.SKY_SELECT}
|
||||||
|
is_seated = room.table_seats.filter(gamer=request.user).exists()
|
||||||
|
ctx["voice_active"] = is_seated and room.table_status in VOICE_PHASES
|
||||||
|
ctx["voice_room_id"] = str(room.id)
|
||||||
|
ctx["voice_muted_at"] = (
|
||||||
|
request.user.voice_muted_at.isoformat()
|
||||||
|
if getattr(request.user, "voice_muted_at", None) else ""
|
||||||
|
)
|
||||||
return render(request, "apps/gameboard/room.html", ctx)
|
return render(request, "apps/gameboard/room.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from django.contrib.auth.models import AnonymousUser
|
|||||||
from django.test import TransactionTestCase, override_settings, tag
|
from django.test import TransactionTestCase, override_settings, tag
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.epic.models import Room, TableSeat
|
||||||
from apps.gameboard.models import SeaInvite
|
from apps.gameboard.models import SeaInvite
|
||||||
from apps.lyric.models import User
|
from apps.lyric.models import User
|
||||||
from apps.voice.consumers import RoomVoiceConsumer
|
from apps.voice.consumers import RoomVoiceConsumer
|
||||||
@@ -81,6 +82,37 @@ class RoomVoiceConsumerGateTest(TransactionTestCase):
|
|||||||
self.assertFalse(connected)
|
self.assertFalse(connected)
|
||||||
|
|
||||||
|
|
||||||
|
@tag("channels")
|
||||||
|
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||||
|
class RoomVoiceConsumerEpicGateTest(TransactionTestCase):
|
||||||
|
"""The epic game-room path of `_can_join`: a SEATED gamer (a TableSeat with
|
||||||
|
their gamer) is admitted to the room's voice mesh, keyed by the bare room
|
||||||
|
UUID (the <str:room_id> route serves both `mysea-…` + UUID schemes); a
|
||||||
|
non-seated user is refused. The consumer needs no my_sea-specific change —
|
||||||
|
this is the room/multi-seat activation path."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.seated = User.objects.create(email="seated@test.io", username="seatd")
|
||||||
|
self.stranger = User.objects.create(email="stranger@test.io", username="strangr")
|
||||||
|
self.room_obj = Room.objects.create(name="Voice Room", owner=self.seated)
|
||||||
|
TableSeat.objects.create(room=self.room_obj, gamer=self.seated, slot_number=1)
|
||||||
|
self.room = str(self.room_obj.id)
|
||||||
|
|
||||||
|
async def test_seated_gamer_connects_and_receives_welcome(self):
|
||||||
|
comm = _comm(self.seated, self.room)
|
||||||
|
connected, _ = await comm.connect()
|
||||||
|
self.assertTrue(connected)
|
||||||
|
msg = await comm.receive_json_from()
|
||||||
|
self.assertEqual(msg["type"], "welcome")
|
||||||
|
self.assertIn("peer_id", msg)
|
||||||
|
await comm.disconnect()
|
||||||
|
|
||||||
|
async def test_non_seated_user_is_rejected(self):
|
||||||
|
comm = _comm(self.stranger, self.room)
|
||||||
|
connected, _ = await comm.connect()
|
||||||
|
self.assertFalse(connected)
|
||||||
|
|
||||||
|
|
||||||
@tag("channels")
|
@tag("channels")
|
||||||
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||||
class RoomVoiceConsumerCapacityTest(TransactionTestCase):
|
class RoomVoiceConsumerCapacityTest(TransactionTestCase):
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ html.sea-open .room-aperture.is-scrollable {
|
|||||||
|
|
||||||
// SCROLL — provenance feed. --priUser font on a --sixUser strip.
|
// SCROLL — provenance feed. --priUser font on a --sixUser strip.
|
||||||
.room-view--scroll .applet-scroll > h2 {
|
.room-view--scroll .applet-scroll > h2 {
|
||||||
color: rgba(var(--priUser), 1);
|
color: rgba(var(--secUser), 1);
|
||||||
background-color: rgba(var(--sixUser), 1);
|
background-color: rgba(var(--sixUser), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +295,7 @@ html.sea-open .room-aperture.is-scrollable {
|
|||||||
// --priUser font on a --sepUser strip.
|
// --priUser font on a --sepUser strip.
|
||||||
.room-view--post {
|
.room-view--post {
|
||||||
.applet-scroll > h2 {
|
.applet-scroll > h2 {
|
||||||
color: rgba(var(--priUser), 1);
|
color: rgba(var(--secUser), 1);
|
||||||
background-color: rgba(var(--sepUser), 1);
|
background-color: rgba(var(--sepUser), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,11 +310,25 @@ html.sea-open .room-aperture.is-scrollable {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Composer stays a flex row: the OK btn sits inline beside the "Enter a
|
||||||
|
// post line" input (the felt/pill chrome moved to YARN, but the
|
||||||
|
// input↔OK row layout is retained — user-spec). Same row as New Post.
|
||||||
|
.post-line-form {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.composer-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.composer-row input.form-control { flex: 1; min-width: 0; width: auto; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PULSE view — forthcoming. --priUser font on an --octUser strip.
|
// PULSE view — forthcoming. --priUser font on an --octUser strip.
|
||||||
.room-view--pulse .applet-scroll > h2 {
|
.room-view--pulse .applet-scroll > h2 {
|
||||||
color: rgba(var(--priUser), 1);
|
color: rgba(var(--secUser), 1);
|
||||||
background-color: rgba(var(--octUser), 1);
|
background-color: rgba(var(--octUser), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -365,9 +365,9 @@
|
|||||||
--terUser: var(--priMze);
|
--terUser: var(--priMze);
|
||||||
--quaUser: var(--priPmm);
|
--quaUser: var(--priPmm);
|
||||||
--quiUser: var(--terPmm);
|
--quiUser: var(--terPmm);
|
||||||
--sixUser: var(--priFor);
|
--sixUser: var(--terCfw);
|
||||||
--sepUser: var(--terFor);
|
--sepUser: var(--terMze);
|
||||||
--octUser: var(--priCfw);
|
--octUser: var(--secCfw);
|
||||||
--ninUser: var(--priCtn);
|
--ninUser: var(--priCtn);
|
||||||
--decUser: var(--terCtn);
|
--decUser: var(--terCtn);
|
||||||
}
|
}
|
||||||
@@ -378,8 +378,8 @@
|
|||||||
--terUser: var(--priFs);
|
--terUser: var(--priFs);
|
||||||
--quaUser: var(--priCfw);
|
--quaUser: var(--priCfw);
|
||||||
--quiUser: var(--terCfw);
|
--quiUser: var(--terCfw);
|
||||||
--sixUser: var(--terId);
|
--sixUser: var(--secId);
|
||||||
--sepUser: var(--secId);
|
--sepUser: var(--quaId);
|
||||||
--octUser: var(--terFs);
|
--octUser: var(--terFs);
|
||||||
--ninUser: var(--sixPu);
|
--ninUser: var(--sixPu);
|
||||||
--decUser: var(--terPu);
|
--decUser: var(--terPu);
|
||||||
|
|||||||
@@ -183,6 +183,11 @@
|
|||||||
<script src="{% static 'apps/epic/tray-tooltip.js' %}"></script>
|
<script src="{% static 'apps/epic/tray-tooltip.js' %}"></script>
|
||||||
<script src="{% static 'apps/epic/position-tooltip.js' %}"></script>
|
<script src="{% static 'apps/epic/position-tooltip.js' %}"></script>
|
||||||
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
|
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
|
||||||
|
{# Voice-affordance glow/pulse machine (keys on voice_active + the live mesh #}
|
||||||
|
{# state) — same as my_sea/my_sea_visit. burger-btn.js owns the click→join; #}
|
||||||
|
{# voice-mesh.js lazy-loads on first join. Coexists w. the sea-btn glow #}
|
||||||
|
{# handoff (sea takes burger precedence; voice is the second-place reveal). #}
|
||||||
|
<script src="{% static 'apps/voice/voice-glow.js' %}"></script>
|
||||||
<script src="{% static 'apps/epic/room-scroll.js' %}"></script>
|
<script src="{% static 'apps/epic/room-scroll.js' %}"></script>
|
||||||
<script src="{% static 'apps/epic/room-views.js' %}"></script>
|
<script src="{% static 'apps/epic/room-views.js' %}"></script>
|
||||||
{% endblock scripts %}
|
{% endblock scripts %}
|
||||||
|
|||||||
Reference in New Issue
Block a user