clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

main
tribes / web / src / pages / simulate.ts
simulate.ts 10.7K
1/** Simulate: compose a 2-8 tribe sim from own/liked/example scripts + my sims. */2import './simulate.css';3import { identicon } from '../identicon';4import type { SimRecord } from '@tribes/protocol';5import { EXAMPLE_ID_PREFIX, LIMITS, mapSizeForTribes } from '@tribes/protocol';6import { api } from '../api';7import { social } from '../api-social';8import { h, clear } from '../dom';9import type { AppCtx } from '../main';10import { signInCard } from './auth-card';1112/** One selectable script in a pool, addressed as createSim expects it. */13interface PoolEntry {14 id: string; // own id | hearted listed id | ex_<name>15 label: string;16 sub: string;17 /** Listed-public at selection time — a full roster of these makes a rated sim. */18 rated: boolean;19}2021export function renderSimulate(main: HTMLElement, ctx: AppCtx): void {22 if (!ctx.user) {23 main.append(signInCard(ctx));24 return;25 }26 main.classList.add('wide');2728 const roster: PoolEntry[] = [];2930 // ---- three pools, fetched in parallel ----------------------------------31 const minePool = makePool('MY SCRIPTS', addToRoster);32 const likedPool = makePool('LIKED', addToRoster);33 const examplesPool = makePool('EXAMPLES', addToRoster);3435 void api36 .listLogic()37 .then((scripts) => {38 minePool.setEntries(39 scripts40 .filter((s) => s.status === 'validated')41 .map((s) => ({ id: s.id, label: s.title ?? s.name, sub: `v${s.version}${s.listed ? ' - listed' : ''}`, rated: s.listed })),42 'no validated scripts - submit one on the create page',43 );44 })45 .catch((err: unknown) => minePool.setError(err));4647 void social48 .listHearts()49 .then((hearts) => {50 likedPool.setEntries(51 hearts.map((s) => ({ id: s.id, label: s.title, sub: `by ${s.owner.name ?? 'anon'}`, rated: true })),52 'nothing liked yet - heart scripts in browse',53 );54 })55 .catch((err: unknown) => likedPool.setError(err));5657 void api58 .listExamples()59 .then((examples) => {60 examplesPool.setEntries(61 examples62 .filter((ex) => ex.kind === 'bot') // libraries are not runnable alone63 .map((ex) => ({ id: `${EXAMPLE_ID_PREFIX}${ex.name}`, label: ex.name, sub: ex.description, rated: false })),64 'no examples available',65 );66 })67 .catch((err: unknown) => examplesPool.setError(err));6869 // ---- roster strip + launch controls ------------------------------------70 const slotsEl = h('div', { class: 'roster-slots' });71 const rosterInfo = h('span', { class: 'roster-info' });72 const ratedNote = h('p', { class: 'rated-note', style: 'display:none' }, 'all participants are public - this sim will be rated');73 const errEl = h('span', { class: 'error-text' });74 const seedInput = h('input', {75 type: 'text',76 class: 'seed-input',77 placeholder: 'random',78 maxLength: 15,79 oninput: () => {80 seedInput.value = seedInput.value.replace(/\D/g, '').slice(0, 15);81 },82 });83 const createBtn = h(84 'button',85 {86 class: 'primary create-btn',87 disabled: true,88 onclick: async () => {89 errEl.textContent = '';90 createBtn.disabled = true;91 createBtn.textContent = 'SPINNING UP -';92 try {93 const sim = await api.createSim(roster.map((r) => r.id), seedInput.value || undefined);94 ctx.navigate(`/s/${sim.id}`);95 } catch (err) {96 errEl.textContent = err instanceof Error ? err.message : String(err);97 createBtn.textContent = 'CREATE SIMULATION';98 paintRoster();99 }100 },101 },102 'CREATE SIMULATION',103 );104105 function addToRoster(entry: PoolEntry): void {106 if (roster.length >= LIMITS.MAX_TRIBES) return;107 roster.push({ ...entry }); // same script may fill several slots108 paintRoster();109 }110111 function paintRoster(): void {112 clear(slotsEl);113 roster.forEach((entry, i) => {114 slotsEl.append(115 h(116 'button',117 { class: 'roster-slot', title: `${entry.id} - click to remove`, onclick: () => {118 roster.splice(i, 1);119 paintRoster();120 } },121 h('span', { class: 'slot-idx' }, String(i + 1)),122 identicon(entry.id, entry.label, 16).el,123 ` ${entry.label} `,124 h('span', { class: 'slot-x' }, 'x'),125 ),126 );127 });128 for (let i = roster.length; i < LIMITS.MIN_TRIBES; i++) {129 slotsEl.append(h('span', { class: 'roster-slot empty' }, `slot ${i + 1}`));130 }131 const n = roster.length;132 if (n >= LIMITS.MIN_TRIBES) {133 const size = mapSizeForTribes(n);134 rosterInfo.textContent = `${n} tribes -> ${size}x${size} map`;135 } else {136 rosterInfo.textContent = `${n}/${LIMITS.MIN_TRIBES} tribes minimum - pick scripts from the pools`;137 }138 createBtn.disabled = n < LIMITS.MIN_TRIBES;139 const allRated = n >= LIMITS.MIN_TRIBES && roster.every((r) => r.rated);140 ratedNote.style.display = allRated ? '' : 'none';141 }142 paintRoster();143144 // ---- my sims ------------------------------------------------------------145 const simsCard = h('div', { class: 'card sims-card' }, h('h2', null, 'My sims'), h('p', { class: 'notice' }, 'scanning the fishbowl - ', h('span', { class: 'blink' }, '█')));146 const refreshSims = async () => {147 const sims = await api.listSims();148 paintSims(simsCard, sims, refreshSims);149 };150 void refreshSims().catch((err: unknown) => {151 clear(simsCard);152 simsCard.append(h('h2', null, 'My sims'), h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));153 });154155 main.append(156 h(157 'div',158 { class: 'sim-wrap' },159 h('div', { class: 'pool-grid' }, minePool.el, likedPool.el, examplesPool.el),160 h(161 'div',162 { class: 'card roster-card' },163 h('h2', null, 'Roster'),164 slotsEl,165 h(166 'div',167 { class: 'row roster-controls' },168 rosterInfo,169 h('span', { class: 'seed-label' }, 'seed'),170 seedInput,171 createBtn,172 errEl,173 ),174 ratedNote,175 ),176 simsCard,177 ),178 );179}180181// --------------------------------------------------------------------------182// Pool panel: punched-label frame, search filter, internally scrolling list183// --------------------------------------------------------------------------184185interface Pool {186 el: HTMLElement;187 setEntries(entries: PoolEntry[], emptyMsg: string): void;188 setError(err: unknown): void;189}190191function makePool(title: string, onPick: (entry: PoolEntry) => void): Pool {192 let entries: PoolEntry[] = [];193 let emptyMsg = 'loading -';194 const list = h('div', { class: 'pool-list' }, h('p', { class: 'pool-empty' }, 'scanning -'));195 const search = h('input', { type: 'text', class: 'pool-search', placeholder: 'filter', oninput: () => paint() });196197 function paint(): void {198 clear(list);199 const q = search.value.trim().toLowerCase();200 const shown = q ? entries.filter((e) => `${e.label} ${e.sub}`.toLowerCase().includes(q)) : entries;201 if (shown.length === 0) {202 list.append(h('p', { class: 'pool-empty' }, entries.length === 0 ? emptyMsg : 'no match'));203 return;204 }205 for (const entry of shown) {206 list.append(207 h(208 'button',209 { class: 'pool-item', title: `add ${entry.label} to the roster`, onclick: () => onPick(entry) },210 identicon(entry.id, entry.label, 18).el,211 h('span', { class: 'pool-item-label' }, entry.label),212 h('span', { class: 'pool-item-sub' }, entry.sub),213 ),214 );215 }216 }217218 return {219 el: h('div', { class: 'pool' }, h('span', { class: 'pool-head' }, title), search, list),220 setEntries(next, msg) {221 entries = next;222 emptyMsg = msg;223 paint();224 },225 setError(err) {226 clear(list);227 list.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));228 },229 };230}231232// --------------------------------------------------------------------------233// My sims table (moved here from the old home-page sims card)234// --------------------------------------------------------------------------235236function fmtDate(iso: string): string {237 return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });238}239240function simStarButton(sim: SimRecord, repaint: () => void): HTMLElement {241 return h(242 'button',243 {244 class: `star-btn${sim.starred ? ' starred' : ''}`,245 title: sim.starred ? 'unstar (allows expiry)' : `star (pin, max ${LIMITS.MAX_STARS})`,246 onclick: async (e: Event) => {247 e.stopPropagation();248 try {249 if (sim.starred) await api.unstar('sim', sim.id);250 else await api.star('sim', sim.id);251 sim.starred = !sim.starred;252 } catch (err) {253 alert(err instanceof Error ? err.message : String(err));254 }255 repaint();256 },257 },258 sim.starred ? '★' : '☆',259 );260}261262function paintSims(card: HTMLElement, sims: SimRecord[], refresh: () => Promise<void>): void {263 clear(card);264 card.append(h('h2', null, 'My sims'));265 if (sims.length === 0) {266 card.append(h('p', { class: 'muted' }, 'No sims yet - build a roster above and hit CREATE.'));267 return;268 }269 const repaint = () => paintSims(card, sims, refresh);270 card.append(271 h(272 'div',273 { class: 'sims-scroll' },274 h(275 'table',276 { class: 'list' },277 h(278 'thead',279 null,280 h(281 'tr',282 null,283 h('th'),284 h('th', null, 'Sim'),285 h('th', null, 'Date'),286 h('th', null, 'Participants'),287 h('th', null, 'Visibility'),288 h('th'),289 ),290 ),291 h(292 'tbody',293 null,294 sims.map((sim) =>295 h(296 'tr',297 null,298 h('td', null, simStarButton(sim, repaint)),299 h('td', null, h('a', { href: `/s/${sim.id}`, class: 'mono' }, sim.id)),300 h('td', { class: 'muted' }, fmtDate(sim.createdAt)),301 h(302 'td',303 { class: 'sim-parts' },304 sim.scripts.map((sc) =>305 h(306 'span',307 { class: 'sim-part', title: sc.scriptName },308 identicon(sc.scriptId, sc.scriptName, 16).el,309 h('span', { class: 'sim-part-name' }, sc.scriptName),310 ),311 ),312 ),313 h(314 'td',315 null,316 sim.visibility === 'public'317 ? h('span', { class: 'vis-badge public', title: 'all participants were listed - rated' }, 'PUBLIC')318 : h('span', { class: 'vis-badge' }, 'private'),319 ),320 h('td', null, h('a', { href: `/s/${sim.id}` }, 'watch')),321 ),322 ),323 ),324 ),325 ),326 );327}