feat(game): official first-move tile draw + admin step-by-step replay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Decide who moves first by the official rule: each seated player draws one tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader remains. Each draw uses honest per-draw crypto/rand entropy (not the deterministic bag seed), so the recorded draw — not a seed — is the only account of the outcome. The leader takes seat 0, so the engine and journal replay are unchanged. The draw is recorded with the game (game_setup_draws, migration 00013) for future tournaments, designed as a discrete "player N draws a tile" step. Friend/AI games draw at creation. Auto-match draws when the game opens, against a synthetic uuid.Nil opponent whose draw rows (NULL account_id) are back-filled to the real opponent on join — so the opener's seat is fixed up front and the existing open-game pre-move is preserved with no reseating. Admin /_gm/games/:id gains the recorded draw list and a simple step-by-step board replay (game.ReplayTimeline + a vanilla-JS stepper): a board with A-O/1-15 headers and highlighted premium squares, placed letters with their tile value as a subscript, rack panels around the board (seat 0 top, 1 bottom, 2 left, 3 right) with the current player highlighted, and a per-move log with the tiles drawn and the bag remainder. Docs: ARCHITECTURE §6/§9, FUNCTIONAL (+_ru), PRERELEASE (FM row), design spec.
This commit is contained in:
@@ -137,3 +137,59 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||
an image attachment is previewed inline, bounded so it cannot dominate the page. */
|
||||
.msgbody { white-space: pre-wrap; word-break: break-word; background: var(--bg); padding: 0.6rem 0.8rem; border-radius: 6px; margin: 0.6rem 0; }
|
||||
.attach { max-width: 100%; max-height: 480px; height: auto; border: 1px solid var(--line); border-radius: 6px; }
|
||||
|
||||
/* Game replay (admin): a script-stepped board with rack panels around it, a move log and the
|
||||
first-move draw. A placed tile shows its value as a subscript; a 0 value (a blank) shows none. */
|
||||
.replay-stage {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-areas: ". top ." "left board right" ". bottom .";
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.rack-top { grid-area: top; }
|
||||
.rack-bottom { grid-area: bottom; }
|
||||
.rack-left { grid-area: left; }
|
||||
.rack-right { grid-area: right; }
|
||||
.replay-board { grid-area: board; overflow: auto; }
|
||||
.board-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4rem repeat(15, 1.7rem);
|
||||
grid-auto-rows: 1.7rem;
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
border: 1px solid var(--line);
|
||||
width: max-content;
|
||||
}
|
||||
.board-grid .bh { display: flex; align-items: center; justify-content: center; font-size: 0.6rem; color: var(--ink-dim); background: var(--panel); }
|
||||
.board-grid .cell { position: relative; display: flex; align-items: center; justify-content: center; background: var(--panel-hi); }
|
||||
.cell .prem { font-size: 0.55rem; color: var(--ink); opacity: 0.8; }
|
||||
.cell.tw { background: #7a2230; }
|
||||
.cell.dw { background: #a8506a; }
|
||||
.cell.tl { background: #235a7a; }
|
||||
.cell.dl { background: #3f87a8; }
|
||||
.cell.centre .prem { font-size: 0.95rem; color: var(--warn); opacity: 1; }
|
||||
.tile {
|
||||
display: inline-flex; align-items: baseline; justify-content: center;
|
||||
min-width: 1.35rem; height: 1.35rem; padding: 0 0.12rem;
|
||||
background: #e8d9a0; color: #1b1408; border-radius: 3px;
|
||||
font-weight: 700; font-size: 0.8rem; line-height: 1.35rem;
|
||||
}
|
||||
.tile sub { font-size: 0.5rem; font-weight: 600; line-height: 1; align-self: flex-end; margin-left: 1px; }
|
||||
.tile.blank { background: #cdbfe0; }
|
||||
.cell.filled { background: var(--panel-hi); }
|
||||
.cell.filled .tile { width: 100%; height: 100%; border-radius: 2px; }
|
||||
.rack-slot { padding: 0.25rem; border-radius: 6px; }
|
||||
.rack-slot.active { outline: 2px solid var(--accent); background: var(--panel-hi); }
|
||||
.rack-name { font-size: 0.72rem; color: var(--ink-dim); margin-bottom: 0.2rem; text-align: center; }
|
||||
.rack-tiles { display: flex; gap: 2px; flex-wrap: wrap; justify-content: center; }
|
||||
.rack-left .rack-tiles, .rack-right .rack-tiles { flex-direction: column; }
|
||||
.replay-controls { display: flex; align-items: center; gap: 0.8rem; justify-content: center; margin: 0.6rem 0; }
|
||||
.replay-controls button { background: var(--panel-hi); color: var(--ink); border: 1px solid var(--line); font-weight: 600; }
|
||||
.replay-controls button:disabled { opacity: 0.4; cursor: default; }
|
||||
.replay-pos { color: var(--ink-dim); font-variant-numeric: tabular-nums; }
|
||||
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
|
||||
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
|
||||
.replay-log li.cur { color: var(--ink); font-weight: 600; }
|
||||
|
||||
@@ -27,5 +27,87 @@
|
||||
</table>
|
||||
{{if .HasRobot}}<p><small>Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.</small></p>{{end}}
|
||||
</section>
|
||||
{{if .SetupDraws}}
|
||||
<section class="panel"><h2>First-move draw</h2>
|
||||
<p class="note">Each player draws a tile; the one closest to “A” moves first (a blank beats every letter), ties re-drawing until a single leader remains.{{if .FirstMover}} <b>{{.FirstMover}}</b> leads.{{end}}</p>
|
||||
<table class="list">
|
||||
<thead><tr><th>Round</th><th>Player</th><th>Tile</th><th>Rank</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .SetupDraws}}
|
||||
<tr><td>{{.Round}}</td><td>{{if .AccountID}}<a href="/_gm/users/{{.AccountID}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td>{{.Letter}}{{if .Blank}} <small>(blank)</small>{{end}}</td><td>{{.Rank}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{if .HasReplay}}
|
||||
<section class="panel"><h2>Replay</h2>
|
||||
<div class="replay-stage">
|
||||
<div class="rack-slot rack-top" data-seat="0"></div>
|
||||
<div class="rack-slot rack-left" data-seat="2"></div>
|
||||
<div class="replay-board" id="replay-board"></div>
|
||||
<div class="rack-slot rack-right" data-seat="3"></div>
|
||||
<div class="rack-slot rack-bottom" data-seat="1"></div>
|
||||
</div>
|
||||
<div class="replay-controls">
|
||||
<button type="button" id="replay-prev">◀ prev</button>
|
||||
<span class="replay-pos" id="replay-pos"></span>
|
||||
<button type="button" id="replay-next">next ▶</button>
|
||||
</div>
|
||||
<ol class="replay-log" id="replay-log"></ol>
|
||||
<script>
|
||||
const REPLAY = {{.ReplayJSON}};
|
||||
(function(){
|
||||
if(!REPLAY||!REPLAY.steps){return;}
|
||||
const N=15, COLS="ABCDEFGHIJKLMNO", PREM={tw:"3W",dw:"2W",tl:"3L",dl:"2L"};
|
||||
const boardEl=document.getElementById("replay-board"), logEl=document.getElementById("replay-log");
|
||||
const posEl=document.getElementById("replay-pos"), prevBtn=document.getElementById("replay-prev"), nextBtn=document.getElementById("replay-next");
|
||||
let step=0;
|
||||
function esc(s){const d=document.createElement("div");d.textContent=s==null?"":s;return d.innerHTML;}
|
||||
function tileHTML(t){const sub=(t.v&&t.v>0)?"<sub>"+t.v+"<\/sub>":"";return "<span class=\"tile"+(t.b?" blank":"")+"\">"+esc(t.l)+sub+"<\/span>";}
|
||||
function placedAt(k){const m={};for(let s=1;s<=k;s++){const mv=REPLAY.steps[s]&&REPLAY.steps[s].move;if(mv&&mv.placements){for(const p of mv.placements){m[p.r+","+p.c]=p;}}}return m;}
|
||||
function renderBoard(){
|
||||
const placed=placedAt(step);let h="<div class=\"board-grid\"><div class=\"bh corner\"><\/div>";
|
||||
for(let c=0;c<N;c++){h+="<div class=\"bh\">"+COLS[c]+"<\/div>";}
|
||||
for(let r=0;r<N;r++){h+="<div class=\"bh\">"+(r+1)+"<\/div>";
|
||||
for(let c=0;c<N;c++){const p=placed[r+","+c];
|
||||
if(p){h+="<div class=\"cell filled\">"+tileHTML(p)+"<\/div>";continue;}
|
||||
const prem=REPLAY.premium[r][c], centre=(r===REPLAY.centre[0]&&c===REPLAY.centre[1]);
|
||||
const label=centre?"★":(prem?PREM[prem]:"");
|
||||
h+="<div class=\"cell "+(prem||"")+(centre?" centre":"")+"\">"+(label?"<span class=\"prem\">"+label+"<\/span>":"")+"<\/div>";}}
|
||||
h+="<\/div>";boardEl.innerHTML=h;
|
||||
}
|
||||
function renderRacks(){
|
||||
const st=REPLAY.steps[step];
|
||||
document.querySelectorAll(".rack-slot").forEach(function(slot){
|
||||
const seat=parseInt(slot.dataset.seat,10), info=REPLAY.seats.find(function(s){return s.seat===seat;}), rack=st.racks[seat];
|
||||
if(!info||!rack){slot.style.display="none";slot.innerHTML="";return;}
|
||||
slot.style.display="";slot.classList.toggle("active",st.toMove===seat);
|
||||
const nm=info.accountId?"<a href=\"/_gm/users/"+info.accountId+"\">"+esc(info.name)+"<\/a>":esc(info.name||("seat "+seat));
|
||||
slot.innerHTML="<div class=\"rack-name\">"+nm+" · "+(st.scores[seat]||0)+"<\/div><div class=\"rack-tiles\">"+rack.map(tileHTML).join("")+"<\/div>";
|
||||
});
|
||||
}
|
||||
function renderLog(){
|
||||
let h="";
|
||||
for(let s=1;s<=step;s++){const st=REPLAY.steps[s], m=st.move;if(!m){continue;}
|
||||
const who=((REPLAY.seats.find(function(x){return x.seat===m.seat;})||{}).name)||("seat "+m.seat);
|
||||
let desc;
|
||||
if(m.action==="play"){desc="played "+((m.words&&m.words.length)?m.words.join(", "):"")+" for "+m.score;}
|
||||
else if(m.action==="exchange"){desc="exchanged "+((m.exchanged&&m.exchanged.length)||0)+" tiles";}
|
||||
else if(m.action==="pass"){desc="passed";}
|
||||
else{desc=m.action;}
|
||||
const drew=(st.drawn&&st.drawn.length)?" · drew "+st.drawn.map(function(t){return t.l;}).join(""):"";
|
||||
h+="<li class=\""+(s===step?"cur":"")+"\">"+esc(who)+" "+esc(desc)+drew+" · bag "+st.bagLen+"<\/li>";}
|
||||
logEl.innerHTML=h||"<li class=\"note\">opening position<\/li>";
|
||||
}
|
||||
function render(){renderBoard();renderRacks();renderLog();posEl.textContent=step+" / "+(REPLAY.steps.length-1);prevBtn.disabled=step<=0;nextBtn.disabled=step>=REPLAY.steps.length-1;}
|
||||
prevBtn.onclick=function(){if(step>0){step--;render();}};
|
||||
nextBtn.onclick=function(){if(step<REPLAY.steps.length-1){step++;render();}};
|
||||
document.addEventListener("keydown",function(e){if(e.key==="ArrowLeft"){prevBtn.click();}else if(e.key==="ArrowRight"){nextBtn.click();}});
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -268,6 +268,27 @@ type GameDetailView struct {
|
||||
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||
HasRobot bool
|
||||
RobotTargetPct int
|
||||
// ReplayJSON is the game-replay payload (board, seats, per-step racks/scores/bag) the
|
||||
// game_detail page feeds to its vanilla-JS stepper; HasReplay gates the replay section.
|
||||
ReplayJSON template.JS
|
||||
HasReplay bool
|
||||
// SetupDraws is the first-move draw — one row per tile drawn (docs/ARCHITECTURE.md §6) —
|
||||
// and FirstMover is the resolved name of the seat-0 player the draw elected.
|
||||
SetupDraws []SetupDrawRow
|
||||
FirstMover string
|
||||
}
|
||||
|
||||
// SetupDrawRow is one tile drawn in the first-move seeding (docs/ARCHITECTURE.md §6): the
|
||||
// round, the player (Name/AccountID, or "(opponent)" with an empty AccountID for an
|
||||
// auto-match synthetic draw not yet back-filled), the drawn letter (upper-cased; "?" for a
|
||||
// blank) and its draw rank.
|
||||
type SetupDrawRow struct {
|
||||
Round int
|
||||
Name string
|
||||
AccountID string
|
||||
Letter string
|
||||
Blank bool
|
||||
Rank int
|
||||
}
|
||||
|
||||
// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
|
||||
|
||||
Reference in New Issue
Block a user