浏览代码

Oasis release 0.7.1

psy 17 小时之前
父节点
当前提交
4f3edce089
共有 47 个文件被更改,包括 1922 次插入542 次删除
  1. 18 1
      docs/CHANGELOG.md
  2. 1 1
      docs/devs/install.md
  3. 160 27
      src/backend/backend.js
  4. 11 9
      src/client/assets/styles/style.css
  5. 13 2
      src/client/assets/translations/oasis_ar.js
  6. 13 2
      src/client/assets/translations/oasis_de.js
  7. 13 2
      src/client/assets/translations/oasis_en.js
  8. 13 2
      src/client/assets/translations/oasis_es.js
  9. 13 2
      src/client/assets/translations/oasis_eu.js
  10. 13 3
      src/client/assets/translations/oasis_fr.js
  11. 13 2
      src/client/assets/translations/oasis_hi.js
  12. 13 2
      src/client/assets/translations/oasis_it.js
  13. 13 2
      src/client/assets/translations/oasis_pt.js
  14. 13 2
      src/client/assets/translations/oasis_ru.js
  15. 13 2
      src/client/assets/translations/oasis_zh.js
  16. 3 3
      src/client/gui.js
  17. 1 0
      src/configs/media-favorites.json
  18. 351 0
      src/games/neoninfiltrator/index.html
  19. 73 0
      src/games/neoninfiltrator/thumbnail.svg
  20. 182 0
      src/games/rockpaperscissors/index.html
  21. 17 0
      src/games/rockpaperscissors/thumbnail.svg
  22. 144 136
      src/games/tiktaktoe/index.html
  23. 15 24
      src/games/tiktaktoe/thumbnail.svg
  24. 4 1
      src/models/activity_model.js
  25. 4 1
      src/models/chats_model.js
  26. 183 32
      src/models/courts_model.js
  27. 1 2
      src/models/feed_model.js
  28. 1 1
      src/models/games_model.js
  29. 11 3
      src/models/jobs_model.js
  30. 11 4
      src/models/market_model.js
  31. 1 0
      src/models/pm_model.js
  32. 10 3
      src/models/shops_model.js
  33. 19 3
      src/models/tags_model.js
  34. 15 0
      src/server/nodemon.json
  35. 328 147
      src/server/package-lock.json
  36. 6 1
      src/server/package.json
  37. 11 2
      src/server/ssb_metadata.js
  38. 32 14
      src/views/blockchain_view.js
  39. 29 23
      src/views/chats_view.js
  40. 1 4
      src/views/cipher_view.js
  41. 27 14
      src/views/event_view.js
  42. 3 3
      src/views/feed_view.js
  43. 3 1
      src/views/games_view.js
  44. 15 0
      src/views/main_views.js
  45. 42 40
      src/views/pads_view.js
  46. 16 2
      src/views/search_view.js
  47. 30 17
      src/views/task_view.js

+ 18 - 1
docs/CHANGELOG.md

@@ -13,7 +13,24 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
-## v0.7.0 - 2026-04-12
+## v0.7.1 - 2026-04-14
+
+### Added
+
+- "nodemon" dev running scripts and documentation (Core plugin).
+- TikTakToe game (Games plugin).
+- Neon Infiltrator (Games plugin).
+
+### Changed
+
+- Console output configuration parameters verbose (Core plugin).
+- More layers of privacy/encryption applied to sensitive content at different places (Core plugin).
+
+### Fixed
+
+- Refeeds activity (Feeds plugin).
+
+## v0.7.0 - 2026-04-11
 
 ### Added
 

+ 1 - 1
docs/devs/install.md

@@ -12,5 +12,5 @@ npm run dev
 
 Once Oasis is started in dev mode, visit [http://localhost:3000](http://localhost:3000). 
 
-While the server processes are running, they will restart theirselves automatically every time you save changes in any file into `/src`. Page autoreload feature is not available even for the development environment because we avoid using JavaScript in the browser, so your browser will remain untouched. Reload the page manually to display the changes.
+The backend restarts automatically (via [nodemon](https://nodemon.io)) whenever you save changes to `.js` or `.json` files in `src/backend/`, `src/models/`, `src/views/`, or `src/client/`. Static assets (`src/client/assets/`) do not trigger a restart. Page autoreload is not available because we avoid using JavaScript in the browser — reload the page manually to display your changes.
 

+ 160 - 27
src/backend/backend.js

@@ -139,6 +139,12 @@ const checkMod = (ctx, mod) => {
   return serverValue === 'on' || serverValue === undefined;
 };
 const getViewerId = () => SSBconfig?.config?.keys?.id || SSBconfig?.keys?.id;
+const getUserTribeIds = async (uid) => {
+  const allTribes = await tribesModel.listAll().catch(() => []);
+  const memberTribes = allTribes.filter(t => t.members.includes(uid));
+  const idSets = await Promise.all(memberTribes.map(t => tribesModel.getChainIds(t.id).catch(() => [t.id])));
+  return new Set(idSets.flat());
+};
 const refreshInboxCount = async (messagesOpt) => {
   const messages = messagesOpt || await pmModel.listAllPrivate();
   const userId = getViewerId();
@@ -233,7 +239,7 @@ const extractMentions = async (text) => {
   }));
   return resolvedMentions;
 };
-const cooler = ssb({ offline: config.offline });
+const cooler = ssb({ offline: config.offline, port: config.port, host: config.host, isPublic: config.public });
 const models = require("../models/main_models");
 const { about, blob, friend, meta, post, vote } = models({
   cooler,
@@ -258,7 +264,6 @@ const reportsModel = require('../models/reports_model')({ cooler, isPublic: conf
 const transfersModel = require('../models/transfers_model')({ cooler, isPublic: config.public });
 const padsModel = require('../models/pads_model')({ cooler, cipherModel });
 const calendarsModel = require('../models/calendars_model')({ cooler, pmModel });
-const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel });
 const cvModel = require('../models/cv_model')({ cooler, isPublic: config.public });
 const inhabitantsModel = require('../models/inhabitants_model')({ cooler, isPublic: config.public });
 const feedModel = require('../models/feed_model')({ cooler, isPublic: config.public });
@@ -270,15 +275,16 @@ const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config
 const trendingModel = require('../models/trending_model')({ cooler, isPublic: config.public });
 const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public });
 const tribesModel = require('../models/tribes_model')({ cooler, isPublic: config.public, tribeCrypto });
+const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel });
 const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel });
 const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel });
 const activityModel = require('../models/activity_model')({ cooler, isPublic: config.public });
 const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: config.public });
-const marketModel = require('../models/market_model')({ cooler, isPublic: config.public });
+const marketModel = require('../models/market_model')({ cooler, isPublic: config.public, tribeCrypto });
 const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public });
 const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public });
-const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public });
-const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public });
+const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public, tribeCrypto });
+const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public, tribeCrypto });
 const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto });
 const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
 const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public });
@@ -286,10 +292,13 @@ const gamesModel = require('../models/games_model')({ cooler });
 const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public });
 const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel });
 const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } });
-const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel } });
+const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel }, tribeCrypto });
 tribesModel.processIncomingKeys().catch(err => {
   if (config.debug) console.error('tribe-keys scan error:', err.message);
 });
+courtsModel.processIncomingCourtsKeys().catch(err => {
+  if (config.debug) console.error('courts-keys scan error:', err.message);
+});
 const getVoteComments = async (voteId) => {
   const raw = await post.topicComments(voteId);
   return (raw || []).filter(c => c?.value?.content?.type === 'post' && c.value.content.root === voteId)
@@ -645,7 +654,7 @@ const resolveCommentComponents = async function (ctx) {
   }
   return { messages, myFeedId, parentMessage, contentWarning };
 };
-const { authorView, previewCommentView, commentView, editProfileView, extendedView, latestView, likesView, threadView, hashtagView, mentionsView, popularView, previewView, privateView, publishCustomView, publishView, previewSubtopicView, subtopicView, imageSearchView, setLanguage, topicsView, summaryView, threadsView } = require("../views/main_views");
+const { authorView, previewCommentView, commentView, editProfileView, extendedView, latestView, likesView, threadView, hashtagView, mentionsView, popularView, previewView, privateView, publishCustomView, publishView, previewSubtopicView, subtopicView, imageSearchView, setLanguage, topicsView, summaryView, threadsView, tribeAccessDeniedView } = require("../views/main_views");
 const { activityView } = require("../views/activity_view");
 const { cvView, createCVView } = require("../views/cv_view");
 const { indexingView } = require("../views/indexing_view");
@@ -685,8 +694,8 @@ const { forumView, singleForumView } = require("../views/forum_view");
 const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view");
 const { jobsView, singleJobsView, renderJobForm } = require("../views/jobs_view");
 const { shopsView, singleShopView, singleProductView, editProductView } = require("../views/shops_view");
-const { chatsView, singleChatView } = require("../views/chats_view");
-const { padsView, singlePadView } = require("../views/pads_view");
+const { chatsView, singleChatView, renderChatInvitePage } = require("../views/chats_view");
+const { padsView, singlePadView, renderPadInvitePage } = require("../views/pads_view");
 const { calendarsView, singleCalendarView } = require("../views/calendars_view");
 const { projectsView, singleProjectView } = require("../views/projects_view")
 const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views")
@@ -822,6 +831,16 @@ router
     let filter = query.filter || 'recent';
     if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all';
     const blockchainData = await blockchainModel.listBlockchain(filter, userId, search);
+    const allTribesList = await tribesModel.listAll().catch(() => []);
+    const anonTribeSet = new Set(allTribesList.filter(tr => tr.isAnonymous === true).map(tr => tr.id));
+    for (const block of blockchainData) {
+      const c = block.content || {};
+      const t = c.type || block.type || '';
+      const isPrivate = String(c.isPublic || '').toLowerCase() === 'private';
+      block.restricted = t === 'tribe' || t.startsWith('courts') || t === 'job' || t === 'job_sub' ||
+        c.status === 'INVITE-ONLY' || c.status === 'PRIVATE' ||
+        (c.tribeId && anonTribeSet.has(c.tribeId)) || isPrivate;
+    }
     ctx.body = renderBlockchainView(blockchainData, filter, userId, search);
   })
   .get('/blockexplorer/block/:id', async (ctx) => {
@@ -839,7 +858,21 @@ router
     const blockId = ctx.params.id;
     const block = await blockchainModel.getBlockById(blockId);
     const viewMode = query.view || 'block';
-    ctx.body = renderSingleBlockView(block, filter, userId, search, viewMode);
+    let restricted = false;
+    if (block) {
+      const c = block.value?.content || {};
+      const t = c.type || '';
+      const allTribes = await tribesModel.listAll().catch(() => []);
+      const anonTribeIds = new Set(allTribes.filter(tr => tr.isAnonymous === true).map(tr => tr.id));
+      const isPrivate = String(c.isPublic || '').toLowerCase() === 'private';
+      restricted = t === 'tribe' ||
+        t.startsWith('courts') ||
+        t === 'job' || t === 'job_sub' ||
+        c.status === 'INVITE-ONLY' || c.status === 'PRIVATE' ||
+        (c.tribeId && anonTribeIds.has(c.tribeId)) ||
+        isPrivate;
+    }
+    ctx.body = renderSingleBlockView(block, filter, userId, search, viewMode, restricted);
   })
   .get("/public/latest", async (ctx) => {
     if (!checkMod(ctx, 'latestMod')) { ctx.redirect('/modules'); return; }
@@ -888,9 +921,23 @@ router
   .get("/search", async (ctx) => {
     const query = ctx.query.query || '';
     if (!query) return ctx.body = await searchView({ messages: [], query, types: [] });
+    const userId = getViewerId();
+    const allTribes = await tribesModel.listAll();
+    const anonTribeIds = new Set(allTribes.filter(t => t.isAnonymous === true).map(t => t.id));
+    const applySearchPrivacy = (msgs) => msgs.filter(msg => {
+      const c = msg.value?.content;
+      if (!c) return true;
+      if (c.tribeId && anonTribeIds.has(c.tribeId)) return false;
+      if (c.type === 'event' && c.isPublic === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false;
+      if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE' && c.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
+      if (c.status === 'PRIVATE') return false;
+      if (c.type === 'shop' && c.visibility === 'CLOSED' && c.author !== userId) return false;
+      return true;
+    });
     const results = await searchModel.search({ query, types: [] });
     ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => {
-      acc[type] = msgs.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
+      const filtered = applySearchPrivacy(msgs).map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
+      if (filtered.length > 0) acc[type] = filtered;
       return acc;
     }, {}), query, types: [] });
   })
@@ -921,10 +968,13 @@ router
   .get("/maps", async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     const { filter = 'all', q = '', lat, lng, zoom, tribeId, title, description, markerLabel, tags, mapType } = ctx.query;
-    const items = await mapsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, viewerId: getViewerId() });
+    const uid = getViewerId();
+    const items = await mapsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, viewerId: uid });
     const fav = await mediaFavorites.getFavoriteSet('maps');
     let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
+    const myTribeIds = await getUserTribeIds(uid);
+    enriched = enriched.filter(x => !x.tribeId || myTribeIds.has(x.tribeId));
     try {
       ctx.body = await mapsView(enriched, filter, null, { q, lat, lng, zoom, title, description, markerLabel, tags, mapType, ...(tribeId ? { tribeId } : {}) });
     } catch (e) {
@@ -941,11 +991,16 @@ router
   .get("/maps/:mapId", async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     const { mapId } = ctx.params; const { filter = 'all', q = '', zoom = '0', mkLat = '', mkLng = '', label: mkMarkerLabel = '' } = ctx.query;
-    const mapItem = await mapsModel.getMapById(mapId, getViewerId());
+    const uid = getViewerId();
+    const mapItem = await mapsModel.getMapById(mapId, uid);
     const fav = await mediaFavorites.getFavoriteSet('maps');
     let tribeMembers = [];
     if (mapItem.tribeId) {
-      try { const t = await tribesModel.getTribeById(mapItem.tribeId); tribeMembers = t.members || []; } catch {}
+      try {
+        const t = await tribesModel.getTribeById(mapItem.tribeId);
+        if (!t.members.includes(uid)) { ctx.body = tribeAccessDeniedView(t); return; }
+        tribeMembers = t.members;
+      } catch { ctx.redirect('/tribes'); return; }
     }
     ctx.body = await singleMapView({ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }, filter, { q, zoom, mkLat, mkLng, mkMarkerLabel, tribeMembers, returnTo: safeReturnTo(ctx, `/maps?filter=${encodeURIComponent(filter)}`, ['/maps']) });
   })
@@ -1840,7 +1895,8 @@ router
     const modelFilter = filter === "favorites" ? "all" : filter;
     const items = await chatsModel.listAll({ filter: modelFilter, q, viewerId });
     const fav = await mediaFavorites.getFavoriteSet('chats');
-    const enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
+    const myTribeIds = await getUserTribeIds(viewerId);
+    const enriched = items.filter(x => !x.tribeId || myTribeIds.has(x.tribeId)).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     const finalList = filter === "favorites" ? enriched.filter(x => x.isFavorite) : enriched;
     ctx.body = await chatsView(finalList, filter, null, { q });
   })
@@ -1853,8 +1909,15 @@ router
   .get("/chats/:chatId", async (ctx) => {
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
     const { filter = 'all', q = '' } = ctx.query;
+    const uid = getViewerId();
     const chat = await chatsModel.getChatById(ctx.params.chatId);
     if (!chat) { ctx.redirect('/chats'); return; }
+    if (chat.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(chat.tribeId);
+        if (!t.members.includes(uid)) { ctx.body = tribeAccessDeniedView(t); return; }
+      } catch { ctx.redirect('/tribes'); return; }
+    }
     const fav = await mediaFavorites.getFavoriteSet('chats');
     const messages = await chatsModel.listMessages(chat.rootId || chat.key);
     ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)) }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']) });
@@ -1875,22 +1938,29 @@ router
     const tribeId = ctx.query.tribeId || "";
     const pads = await padsModel.listAll({ filter, viewerId: uid });
     const fav = await mediaFavorites.getFavoriteSet('pads');
-    const enriched = pads.map(p => ({ ...p, isFavorite: fav.has(String(p.rootId)) }));
+    const myTribeIds = await getUserTribeIds(uid);
+    const enriched = pads.filter(p => !p.tribeId || myTribeIds.has(p.tribeId)).map(p => ({ ...p, isFavorite: fav.has(String(p.rootId)) }));
     ctx.body = await padsView(enriched, filter, null, { q, ...(tribeId ? { tribeId } : {}) });
   })
   .get("/pads/:padId", async (ctx) => {
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
     const pad = await padsModel.getPadById(ctx.params.padId);
     if (!pad) { ctx.redirect('/pads'); return; }
+    if (pad.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(pad.tribeId);
+        if (!t.members.includes(uid)) { ctx.body = tribeAccessDeniedView(t); return; }
+      } catch { ctx.redirect('/tribes'); return; }
+    }
     const fav = await mediaFavorites.getFavoriteSet('pads');
     const entries = await padsModel.getEntries(pad.rootId);
-    const inviteCode = ctx.query.inviteCode || null;
     const versionKey = ctx.query.version || null;
     const selectedVersion = versionKey
       ? (entries.find(e => e.key === versionKey) || entries[parseInt(versionKey)] || null)
       : null;
     const baseUrl = `${ctx.protocol}://${ctx.host}`;
-    ctx.body = await singlePadView({ ...pad, isFavorite: fav.has(String(pad.rootId)) }, entries, { baseUrl, inviteCode, selectedVersion });
+    ctx.body = await singlePadView({ ...pad, isFavorite: fav.has(String(pad.rootId)) }, entries, { baseUrl, selectedVersion });
   })
   .get("/calendars", async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
@@ -1909,14 +1979,22 @@ router
     const modelFilter = filter === "favorites" ? "all" : filter;
     const calendars = await calendarsModel.listAll({ filter: modelFilter, viewerId: uid });
     const fav = await mediaFavorites.getFavoriteSet('calendars');
-    const enriched = calendars.map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) }));
+    const myTribeIds = await getUserTribeIds(uid);
+    const enriched = calendars.filter(c => !c.tribeId || myTribeIds.has(c.tribeId)).map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) }));
     const finalList = filter === "favorites" ? enriched.filter(c => c.isFavorite) : enriched;
     ctx.body = await calendarsView(finalList, filter, null, { q, ...(tribeId ? { tribeId } : {}) });
   })
   .get("/calendars/:calId", async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
     const cal = await calendarsModel.getCalendarById(ctx.params.calId);
     if (!cal) { ctx.redirect('/calendars'); return; }
+    if (cal.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(cal.tribeId);
+        if (!t.members.includes(uid)) { ctx.body = tribeAccessDeniedView(t); return; }
+      } catch { ctx.redirect('/tribes'); return; }
+    }
     const dates = await calendarsModel.getDatesForCalendar(cal.rootId);
     const notesByDate = {};
     for (const d of dates) {
@@ -2301,9 +2379,23 @@ router
     if (typeof types === "string") types = [types];
     if (!Array.isArray(types)) types = [];
     if (!query) return ctx.body = await searchView({ messages: [], query, types });
+    const userId = getViewerId();
+    const allTribes = await tribesModel.listAll();
+    const anonTribeIds = new Set(allTribes.filter(t => t.isAnonymous === true).map(t => t.id));
+    const applySearchPrivacy = (msgs) => msgs.filter(msg => {
+      const c = msg.value?.content;
+      if (!c) return true;
+      if (c.tribeId && anonTribeIds.has(c.tribeId)) return false;
+      if (c.type === 'event' && c.isPublic === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false;
+      if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE' && c.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
+      if (c.status === 'PRIVATE') return false;
+      if (c.type === 'shop' && c.visibility === 'CLOSED' && c.author !== userId) return false;
+      return true;
+    });
     const results = await searchModel.search({ query, types });
     ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => {
-      acc[type] = msgs.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
+      const filtered = applySearchPrivacy(msgs).map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
+      if (filtered.length > 0) acc[type] = filtered;
       return acc;
     }, {}), query, types });
   })
@@ -2438,6 +2530,8 @@ router
     ctx.redirect(ctx.get('referer') || `/forum/${encodeURIComponent(ctx.params.forumId)}`);
   })
   .post('/forum/delete/:id', koaBody(), async ctx => {
+    const forum = await forumModel.getForumById(ctx.params.id).catch(() => null);
+    if (!forum || forum.author !== getViewerId()) { ctx.status = 403; ctx.body = 'Forbidden'; return; }
     await forumModel.deleteForumById(ctx.params.id);
     ctx.redirect('/forum');
   })
@@ -2499,7 +2593,11 @@ router
     ctx.redirect(ctx.get("Referer") || "/feed");
   })
   .post("/feed/refeed/:id", koaBody(), async (ctx) => {
-    await feedModel.createRefeed(ctx.params.id);
+    try {
+      await feedModel.createRefeed(ctx.params.id);
+    } catch (e) {
+      if (e.message !== "Already refeeded") throw e;
+    }
     ctx.redirect(ctx.get("Referer") || "/feed");
   })
   .post("/feed/:feedId/comments", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
@@ -2567,11 +2665,12 @@ router
   .post("/maps/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'maps', 'remove'))
   .post("/maps/:mapId/marker", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
-    const mapItem = await mapsModel.getMapById(ctx.params.mapId, getViewerId());
-    if (mapItem.tribeId && mapItem.mapType === "OPEN") {
+    const uid = getViewerId();
+    const mapItem = await mapsModel.getMapById(ctx.params.mapId, uid);
+    if (mapItem.tribeId) {
       try {
         const t = await tribesModel.getTribeById(mapItem.tribeId);
-        if (!t.members.includes(getViewerId())) { ctx.status = 403; ctx.body = "Forbidden"; return; }
+        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
       } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
     }
     const b = ctx.request.body;
@@ -2995,6 +3094,8 @@ router
     ctx.redirect('/parliament?filter=proposals');
   })
   .post('/parliament/proposals/close/:id', koaBody(), async (ctx) => {
+    const canClose = await parliamentModel.canPropose();
+    if (!canClose) { ctx.status = 403; ctx.body = 'Forbidden'; return; }
     await parliamentModel.closeProposal(ctx.params.id).catch(e => ctx.throw(400, String(e?.message || e)));
     ctx.redirect('/parliament?filter=proposals');
   })
@@ -3316,7 +3417,7 @@ router
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
     const chatId = ctx.request.body.chatId;
     const code = await chatsModel.generateInvite(chatId);
-    ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(chatId)}?inviteCode=${encodeURIComponent(code)}`, ['/chats']));
+    ctx.body = renderChatInvitePage(code);
   })
   .post("/chats/join-code", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
@@ -3346,6 +3447,14 @@ router
   .post("/chats/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'chats', 'remove'))
   .post("/chats/:chatId/message", koaBody({ multipart: true }), async (ctx) => {
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
+    const chat = await chatsModel.getChatById(ctx.params.chatId);
+    if (chat && chat.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(chat.tribeId);
+        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
+      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+    }
     const text = stripDangerousTags(String(ctx.request.body.text || '').trim());
     const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null;
     if (!text && !imageBlob) { ctx.redirect(`/chats/${encodeURIComponent(ctx.params.chatId)}`); return; }
@@ -3391,7 +3500,7 @@ router
   .post("/pads/generate-invite/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     const code = await padsModel.generateInvite(ctx.params.id);
-    ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}?inviteCode=${encodeURIComponent(code)}`);
+    ctx.body = renderPadInvitePage(code);
   })
   .post("/pads/join-code", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
@@ -3411,6 +3520,14 @@ router
   })
   .post("/pads/entry/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
+    const pad = await padsModel.getPadById(ctx.params.id);
+    if (pad && pad.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(pad.tribeId);
+        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
+      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+    }
     const b = ctx.request.body || {};
     const text = stripDangerousTags(String(b.text || "").trim());
     if (text) await padsModel.addEntry(ctx.params.id, text);
@@ -3473,6 +3590,14 @@ router
   })
   .post("/calendars/add-date/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
+    const calForGate = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
+    if (calForGate && calForGate.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(calForGate.tribeId);
+        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
+      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+    }
     const b = ctx.request.body || {};
     const intervalWeekly  = [].concat(b.intervalWeekly).includes("1");
     const intervalMonthly = [].concat(b.intervalMonthly).includes("1");
@@ -3494,6 +3619,14 @@ router
   })
   .post("/calendars/add-note/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
+    const calForGate = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
+    if (calForGate && calForGate.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(calForGate.tribeId);
+        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
+      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+    }
     const b = ctx.request.body || {};
     const text = stripDangerousTags(String(b.text || "").trim());
     if (text) {
@@ -3676,7 +3809,7 @@ router
     ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
   })
   .post("/banking/addresses/delete", koaBody(), async (ctx) => {
-    const res = await bankingModel.removeAddress({ userId: ((ctx.request.body?.userId) || "").trim() });
+    const res = await bankingModel.removeAddress({ userId: getViewerId() });
     ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
   })
   .post("/favorites/remove/:kind/:id", koaBody(), async (ctx) => {

+ 11 - 9
src/client/assets/styles/style.css

@@ -3694,7 +3694,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .calendar-interval-block { margin: 8px 0 16px; }
 .calendar-interval-label { display: block; margin-bottom: 6px; }
 .calendar-interval-until { margin-top: 10px; }
-.calendar-interval-row { display: flex; gap: 16px; flex-wrap: wrap; border: 0px; }
+.calendar-interval-row { display: flex; gap: 16px; flex-wrap: wrap; }
 .calendar-interval-option { display: flex; align-items: center; gap: 4px; cursor: pointer; white-space: nowrap; }
 
 .tribe-thumb-grid {
@@ -4891,8 +4891,8 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .pad-status-invite{color:#FFA500;font-weight:bold}
 .pad-status-closed{font-weight:bold}
 .pad-deadline{color:#FFA500;font-size:0.9rem}
-.pad-editor-container{display:flex;gap:16px;margin-top:12px}
-.pad-editor-area{flex:2;display:flex;flex-direction:column;gap:8px}
+.pad-editor-container{margin-top:12px}
+.pad-editor-area{display:flex;flex-direction:column;gap:8px;}
 .pad-editor-area textarea{width:100%;min-height:200px;background:#1a1a1a;color:#eee;border:1px solid #444;border-radius:4px;padding:10px;font-family:monospace;font-size:0.95rem;resize:vertical;box-sizing:border-box}
 .pad-members-list{flex:1;min-width:160px;background:#1a1a1a;border:1px solid #333;border-radius:4px;padding:10px}
 .pad-members-list h4{margin:0 0 8px 0;color:#aaa;font-size:0.85rem;text-transform:uppercase}
@@ -4944,7 +4944,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 
 .visit-btn-centered{display:flex;justify-content:center;margin-top:10px}
 
-.pad-editor-white{width:100%;box-sizing:border-box;background:#fff;color:#111;border:1px solid #aaa;border-radius:4px;padding:10px;resize:vertical;font-family:inherit;font-size:0.95rem}
+.pad-editor-white{width:100%;box-sizing:border-box;background:#fff;color:#111;border-radius:4px;padding:10px;resize:vertical;font-family:inherit;font-size:0.95rem}
 .pad-readonly-text{background:#fff;color:#111;border:1px solid #aaa;border-radius:4px;padding:10px;white-space:pre-wrap;word-break:break-word;font-size:0.95rem}
 .pad-version-list{margin-top:12px}
 .pad-version-item{display:flex;flex-direction:column;gap:4px;padding:8px;border-bottom:1px solid #333;font-size:0.85rem}
@@ -4970,9 +4970,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .pad-members-below-tags{margin-top:12px;border:none}
 .pad-member-card{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:0.85rem}
 .pad-members-grid{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}
-.pad-main-layout{display:flex;gap:20px}
-.pad-main-left{flex:2;min-width:0}
-.pad-main-right{flex:1;min-width:180px}
+.pad-version-section{margin-top:16px;border-top:1px solid #333;padding-top:12px}
 
 .chat-full-width{flex:1;min-width:0}
 .chat-participants-section{margin-top:12px;padding:12px;background:#1a1a1a;border:none;border-radius:4px}
@@ -4996,8 +4994,10 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .game-shell-section{padding:0!important;margin:0}
 .game-iframe{width:100%;height:82vh;border:none;background:#000;display:block}
 .game-iframe-ecoinflow{height:95vh}
+.game-iframe-neoninfiltrator{height:95vh}
 .game-iframe-audiopendulum{height:95vh}
 .game-iframe-flipflop{height:720px}
+.game-iframe-rockpaperscissors{height:580px}
 .game-iframe-tiktaktoe{height:580px}
 .game-desc-yellow{color:yellow}
 .game-new-record-label{color:#FFA500;font-weight:bold}
@@ -5010,10 +5010,12 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .calendar-day-marked a{color:#000;text-decoration:none;display:block}
 .calendar-nav{display:flex;justify-content:space-between;align-items:center;margin:8px 0}
 .calendar-date-list{margin-top:16px}
-.calendar-date-item{background:#333;border-radius:6px;margin-bottom:14px;position:relative;min-height:64px}
+.calendar-date-item{background:#333;padding:18px 108px 18px 18px;border-radius:6px;margin-bottom:14px;position:relative;min-height:64px}
 .calendar-date-delete{position:absolute;top:12px;right:12px;margin:0}
-.calendar-date-delete button{padding:4px 10px;font-size:0.8rem;top:12px;margin-top: 25px;margin-right: 35px;}
+.calendar-date-delete button{padding:4px 10px;font-size:0.8rem}
 .calendar-date-item-header{color:#FFA500;font-weight:bold;margin-bottom:6px}
 .calendar-participants-count{color:#FFA500;font-weight:bold}
 .calendar-day-notes{margin-top:16px}
 .pad-viewer-back{margin-bottom:12px}
+
+.access-denied-msg{margin:12px 0;padding:12px;background:#1a1a1a;border:1px solid #555;border-radius:4px;color:#ccc;font-style:italic}

+ 13 - 2
src/client/assets/translations/oasis_ar.js

@@ -2942,6 +2942,8 @@ module.exports = {
     gamesCocolandDesc: "جوزة هند بعيون تقفز فوق أشجار النخيل وتجمع ECOins.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "اربط PUBs بالسكان عبر المدققين والمتاجر والمجمّعات. انجُ من تهديد CBDC!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "تسلل عبر الشبكة، اجمع بيانات سرية، تجنب طائرات الأمن واهرب. كم مستوى تستطيع اجتيازه؟",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "أوقف الغزو الفضائي! أسقط موجات الغزاة قبل أن يصلوا إلى الأرض.",
     gamesArkanoidTitle: "Arkanoid",
@@ -2952,8 +2954,10 @@ module.exports = {
     gamesOutrunDesc: "سباق ضد الوقت! تجنب السيارات وصل إلى خط النهاية قبل انتهاء الوقت.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "قد مركبتك عبر حقل الكويكبات. دمرها قبل أن تصطدم بك.",
+    gamesRockPaperScissorsTitle: "حجر ورقة مقص",
+    gamesRockPaperScissorsDesc: "حجر، ورقة أو مقص ضد ذكاء اصطناعي. الأفضل في ثلاث جولات يفوز.",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "حجر، ورقة أو مقص ضد ذكاء اصطناعي. الأفضل في ثلاث جولات يفوز.",
+    gamesTikTakToeDesc: "تيك-تاك-تو الكلاسيكي ضد الذكاء الاصطناعي. ثلاثة في صف واحد للفوز.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "اقلب عملة معدنية وراهن على الوجه أو الظهر. كم أنت محظوظ?",
     games8BallTitle: "8Ball Pool",
@@ -3041,6 +3045,13 @@ module.exports = {
     calendarLeave: "مغادرة التقويم",
     statsCalendar: "التقاويم",
     statsCalendarDate: "تواريخ التقويم",
-    statsCalendarNote: "ملاحظات التقويم"
+    statsCalendarNote: "ملاحظات التقويم",
+    chatAccessDenied: "ليس لديك حق الوصول إلى هذه المحادثة. اطلب دعوة للوصول إلى المحتوى.",
+    padAccessDenied: "ليس لديك حق الوصول إلى هذا الوسادة. اطلب دعوة للوصول إلى المحتوى.",
+    contentAccessDenied: "ليس لديك حق الوصول إلى هذا المحتوى.",
+    blockAccessRestricted: "الوصول مقيد",
+    tribeContentAccessDenied: "تم رفض الوصول",
+    tribeContentAccessDeniedMsg: "هذا المحتوى ينتمي إلى قبيلة. يجب أن تكون عضواً للوصول إليه.",
+    tribeViewTribes: "عرض القبائل"
     }
 };

+ 13 - 2
src/client/assets/translations/oasis_de.js

@@ -2885,6 +2885,8 @@ module.exports = {
     gamesCocolandDesc: "Eine Kokosnuss mit Augen, die über Palmen springt und ECOins sammelt.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Verbinde PUBs mit Bewohnern über Validatoren, Shops und Akkumulatoren. Überstehe die CBDC-Bedrohung!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Infiltriere das Gitter, sammle vertrauliche Daten, weiche Sicherheitsdrohnen aus und fliehe. Wie viele Level schaffst du?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Stoppt die Alien-Invasion! Schießt Wellen von Eindringlingen ab.",
     gamesArkanoidTitle: "Arkanoid",
@@ -2895,8 +2897,10 @@ module.exports = {
     gamesOutrunDesc: "Rennen gegen die Zeit! Weiche dem Verkehr aus und erreiche das Ziel rechtzeitig.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Steuere dein Raumschiff durch ein tödliches Asteroidenfeld. Schieße sie ab, bevor sie dich treffen.",
+    gamesRockPaperScissorsTitle: "Schere Stein Papier",
+    gamesRockPaperScissorsDesc: "Schere, Stein, Papier gegen eine KI. Das Beste aus drei Runden gewinnt.",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Schere, Stein, Papier gegen eine KI. Das Beste aus drei Runden gewinnt.",
+    gamesTikTakToeDesc: "Klassisches Tic-Tac-Toe gegen die KI. Drei in einer Reihe gewinnt.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Werfe eine Münze und wette auf Kopf oder Zahl. Wie viel Glück hast du?",
     games8BallTitle: "8Ball Pool",
@@ -3037,6 +3041,13 @@ module.exports = {
     calendarLeave: "Kalender verlassen",
     statsCalendar: "Kalender",
     statsCalendarDate: "Kalenderdaten",
-    statsCalendarNote: "Kalendernotizen"
+    statsCalendarNote: "Kalendernotizen",
+    chatAccessDenied: "Sie haben keinen Zugang zu diesem Chat. Fordern Sie eine Einladung an.",
+    padAccessDenied: "Sie haben keinen Zugang zu diesem Pad. Fordern Sie eine Einladung an.",
+    contentAccessDenied: "Sie haben keinen Zugang zu diesem Inhalt.",
+    blockAccessRestricted: "Zugang gesperrt",
+    tribeContentAccessDenied: "Zugang Verweigert",
+    tribeContentAccessDeniedMsg: "Dieser Inhalt gehört zu einem Stamm. Sie müssen Mitglied sein, um darauf zuzugreifen.",
+    tribeViewTribes: "Stämme Anzeigen"
     }
 }

+ 13 - 2
src/client/assets/translations/oasis_en.js

@@ -3019,6 +3019,8 @@ module.exports = {
     gamesCocolandDesc: "A coconut with eyes jumping over palm trees and collecting ECOins. How far can you go?",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Connect PUBs to habitants through validators, shops and accumulators. Survive the CBDC threat!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Infiltrate the grid, collect confidential data, evade security drones and escape. How many levels can you clear?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Stop the alien invasion! Shoot down waves of invaders before they reach the ground.",
     gamesArkanoidTitle: "Arkanoid",
@@ -3029,8 +3031,10 @@ module.exports = {
     gamesOutrunDesc: "Race against time! Dodge traffic and reach the finish line before the clock runs out.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Pilot your ship through a deadly asteroid field. Shoot them down before they hit you.",
+    gamesRockPaperScissorsTitle: "Rock Paper Scissors",
+    gamesRockPaperScissorsDesc: "Rock, Paper, Scissors against an AI. Best of three rounds wins.",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Rock, Paper, Scissors against an AI. Best of three rounds wins.",
+    gamesTikTakToeDesc: "Classic Tic-Tac-Toe against an AI. Get three in a row to win.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Flip a coin and bet on heads or tails. How lucky are you?",
     games8BallTitle: "8Ball Pool",
@@ -3060,7 +3064,14 @@ module.exports = {
     statsChatMessage: "Chat messages",
     statsPad: "Pads",
     statsPadEntry: "Pad entries",
-    statsGameScore: "Game scores"
+    statsGameScore: "Game scores",
+    chatAccessDenied: "You do not have access to the chat. Ask for an invitation to access the content.",
+    padAccessDenied: "You do not have access to the pad. Ask for an invitation to access the content.",
+    contentAccessDenied: "You do not have access to content.",
+    blockAccessRestricted: "Access restricted",
+    tribeContentAccessDenied: "Access Denied",
+    tribeContentAccessDeniedMsg: "This content belongs to a tribe. You must be a member to access it.",
+    tribeViewTribes: "View Tribes"
 
     }
 };

+ 13 - 2
src/client/assets/translations/oasis_es.js

@@ -3019,6 +3019,8 @@ module.exports = {
     gamesCocolandDesc: "Un coco con ojos saltando palmeras y coleccionando ECOins. ¿Hasta dónde puedes llegar?",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Conecta PUBs a habitantes mediante validadores, tiendas y acumuladores. ¡Sobrevive a la amenaza CBDC!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Infiltra la cuadricula, recolecta datos confidenciales, evade los drones de seguridad y escapa. ¿Cuantos niveles puedes superar?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "¡Detén la invasión alienígena! Destruye oleadas de invasores antes de que lleguen al suelo.",
     gamesArkanoidTitle: "Arkanoid",
@@ -3029,8 +3031,10 @@ module.exports = {
     gamesOutrunDesc: "¡Corre contra el tiempo! Esquiva el tráfico y llega a la meta antes de que se acabe el tiempo.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Pilota tu nave por un campo de asteroides mortal. Dispárales antes de que te alcancen.",
+    gamesRockPaperScissorsTitle: "Piedra Papel Tijeras",
+    gamesRockPaperScissorsDesc: "Piedra, papel o tijera contra una IA. Gana el mejor de tres rondas.",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Piedra, papel o tijera contra una IA. Gana el mejor de tres rondas.",
+    gamesTikTakToeDesc: "Tres en raya clásico contra la IA. Consigue tres en línea para ganar.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Lanza una moneda y apuesta cara o cruz. ¿Cuánta suerte tienes?",
     games8BallTitle: "8Ball Pool",
@@ -3051,6 +3055,13 @@ module.exports = {
     gamesHallOfFame: "Hall of Fame",
     gamesHallPlayer: "Player",
     gamesHallScore: "Score",
-    gamesNoScores: "No scores yet."
+    gamesNoScores: "No scores yet.",
+    chatAccessDenied: "No tienes acceso a este chat. Solicita una invitación para acceder al contenido.",
+    padAccessDenied: "No tienes acceso a este pad. Solicita una invitación para acceder al contenido.",
+    contentAccessDenied: "No tienes acceso a este contenido.",
+    blockAccessRestricted: "Acceso restringido",
+    tribeContentAccessDenied: "Acceso Denegado",
+    tribeContentAccessDeniedMsg: "Este contenido pertenece a una tribu. Debes ser miembro para acceder.",
+    tribeViewTribes: "Ver Tribus"
     }
 };

+ 13 - 2
src/client/assets/translations/oasis_eu.js

@@ -2912,6 +2912,8 @@ module.exports = {
     gamesCocolandDesc: "Begiak dituen koko bat palmondoen gainetik jauzika ECOins biltzen.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Lotu PUBak biztanleekin baliozkotzaileen, denden eta metatzaileen bidez. Iraun CBDC mehatxuari!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Sartu saretik, bildu datu konfidentzialak, saihestu segurtasun dronak eta ihes egin. Zenbat maila gainditu ditzakezu?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Gelditu inbasio extraterrestrea! Inbasore olatuak bota.",
     gamesArkanoidTitle: "Arkanoid",
@@ -2922,8 +2924,10 @@ module.exports = {
     gamesOutrunDesc: "Denboraren aurka lasterketa! Saihestu trafikoa eta iritsi helmugara garaiz.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Gidatu zure ontzia asteroide eremuaren bidez. Tiro egin haiek jo aurretik.",
+    gamesRockPaperScissorsTitle: "Harria Paper Artaziak",
+    gamesRockPaperScissorsDesc: "Harria, papera edo artaziak IA baten aurka. Hiru txandako onena irabazten du.",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Harria, papera edo artaziak IA baten aurka. Hiru txandako onena irabazten du.",
+    gamesTikTakToeDesc: "Tic-Tac-Toe klasikoa IA aurka. Hiru jarraian lortu irabazteko.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Bota txanpon bat eta apostatu aurpegian edo buztanean. Zenbat zorte duzu?",
     games8BallTitle: "8Ball Pool",
@@ -3011,6 +3015,13 @@ module.exports = {
     calendarLeave: "Irten Egutegitik",
     statsCalendar: "Egutegiak",
     statsCalendarDate: "Egutegi datak",
-    statsCalendarNote: "Egutegi oharrak"
+    statsCalendarNote: "Egutegi oharrak",
+    chatAccessDenied: "Ez duzu sarbiderik txat honetara. Eskatu gonbidapen bat edukira sartzeko.",
+    padAccessDenied: "Ez duzu sarbiderik pad honetara. Eskatu gonbidapen bat edukira sartzeko.",
+    contentAccessDenied: "Ez duzu sarbiderik eduki honetara.",
+    blockAccessRestricted: "Sarbidea mugatuta",
+    tribeContentAccessDenied: "Sarbidea Ukatua",
+    tribeContentAccessDeniedMsg: "Eduki hau tribu batena da. Kide izan behar zara sartzeko.",
+    tribeViewTribes: "Tribuak Ikusi"
   }
 };

+ 13 - 3
src/client/assets/translations/oasis_fr.js

@@ -2940,6 +2940,8 @@ module.exports = {
     gamesCocolandDesc: "Une noix de coco avec des yeux qui saute par-dessus des palmiers en collectant des ECOins.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Connectez les PUBs aux habitants via validateurs, boutiques et accumulateurs. Résistez à la menace CBDC !",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Infiltrez la grille, collectez les données confidentielles, évitez les drones et échappez-vous. Combien de niveaux pouvez-vous passer?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Arrêtez l'invasion extraterrestre! Abattez les vagues d'envahisseurs.",
     gamesArkanoidTitle: "Arkanoid",
@@ -2950,8 +2952,10 @@ module.exports = {
     gamesOutrunDesc: "Course contre la montre! Évitez les obstacles et atteignez l'arrivée à temps.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Pilotez votre vaisseau dans un champ d'astéroïdes. Détruisez-les avant qu'ils ne vous touchent.",
+    gamesRockPaperScissorsTitle: "Pierre Feuille Ciseaux",
+    gamesRockPaperScissorsDesc: "Pierre, papier, ciseaux contre une IA. Le meilleur des trois manches gagne.",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Pierre, papier, ciseaux contre une IA. Le meilleur des trois manches gagne.",
+    gamesTikTakToeDesc: "Morpion classique contre l'IA. Alignez trois symboles pour gagner.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Lancez une pièce et pariez sur pile ou face. Quelle est votre chance?",
     games8BallTitle: "8Ball Pool",
@@ -3039,7 +3043,13 @@ module.exports = {
     calendarLeave: "Quitter le calendrier",
     statsCalendar: "Calendriers",
     statsCalendarDate: "Dates de calendrier",
-    statsCalendarNote: "Notes de calendrier"
+    statsCalendarNote: "Notes de calendrier",
+    chatAccessDenied: "Vous n'avez pas accès à ce chat. Demandez une invitation pour accéder au contenu.",
+    padAccessDenied: "Vous n'avez pas accès à ce pad. Demandez une invitation pour accéder au contenu.",
+    contentAccessDenied: "Vous n'avez pas accès à ce contenu.",
+    blockAccessRestricted: "Accès restreint",
+    tribeContentAccessDenied: "Accès Refusé",
+    tribeContentAccessDeniedMsg: "Ce contenu appartient à une tribu. Vous devez en être membre pour y accéder.",
+    tribeViewTribes: "Voir les Tribus"
     }
 };
-// calendar keys added

+ 13 - 2
src/client/assets/translations/oasis_hi.js

@@ -2942,6 +2942,8 @@ module.exports = {
     gamesCocolandDesc: "आंखों वाला एक नारियल ताड़ के पेड़ों के ऊपर कूदता है और ECOins इकट्ठा करता है।",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "PUBs को validators, shops और accumulators के ज़रिए habitants से जोड़ें। CBDC के खतरे से बचें!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "ग्रिड में घुसें, गोपनीय डेटा इकट्ठा करें, सुरक्षा ड्रोन से बचें और भागें. कितने लेवल साफ कर सकते हैं?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "एलियन आक्रमण रोकें! आक्रमणकारियों की लहरों को मार गिराएं।",
     gamesArkanoidTitle: "Arkanoid",
@@ -2952,8 +2954,10 @@ module.exports = {
     gamesOutrunDesc: "समय के खिलाफ दौड़! यातायात से बचें और समय से पहले मंजिल पर पहुंचें।",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "क्षुद्रग्रह क्षेत्र में अपना यान उड़ाएं। उन्हें आने से पहले नष्ट करें।",
+    gamesRockPaperScissorsTitle: "पत्थर कागज कैंची",
+    gamesRockPaperScissorsDesc: "AI के खिलाफ पत्थर, कागज या कैंची। तीन में से दो जीतने वाला विजेता।",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "AI के खिलाफ पत्थर, कागज या कैंची। तीन में से दो जीतने वाला विजेता।",
+    gamesTikTakToeDesc: "AI के खिलाफ क्लासिक टिक-टैक-टो। तीन एक पंक्ति में जीत है।",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "एक सिक्का उछालें और हेड या टेल पर दांव लगाएं। आप कितने भाग्यशाली हैं?",
     games8BallTitle: "8Ball Pool",
@@ -3041,6 +3045,13 @@ module.exports = {
     calendarLeave: "कैलेंडर छोड़ें",
     statsCalendar: "कैलेंडर",
     statsCalendarDate: "कैलेंडर तारीखें",
-    statsCalendarNote: "कैलेंडर नोट"
+    statsCalendarNote: "कैलेंडर नोट",
+    chatAccessDenied: "आपके पास इस चैट तक पहुंच नहीं है। सामग्री तक पहुंचने के लिए आमंत्रण मांगें।",
+    padAccessDenied: "आपके पास इस पैड तक पहुंच नहीं है। सामग्री तक पहुंचने के लिए आमंत्रण मांगें।",
+    contentAccessDenied: "आपके पास इस सामग्री तक पहुंच नहीं है।",
+    blockAccessRestricted: "पहुंच प्रतिबंधित",
+    tribeContentAccessDenied: "पहुंच अस्वीकृत",
+    tribeContentAccessDeniedMsg: "यह सामग्री एक जनजाति की है। इसे देखने के लिए आपको सदस्य होना चाहिए।",
+    tribeViewTribes: "जनजातियाँ देखें"
     }
 };

+ 13 - 2
src/client/assets/translations/oasis_it.js

@@ -2943,6 +2943,8 @@ module.exports = {
     gamesCocolandDesc: "Una noce di cocco con occhi che salta palme e raccoglie ECOins.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Collega i PUB agli abitanti tramite validatori, negozi e accumulatori. Sopravvivi alla minaccia CBDC!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Infiltra la griglia, raccogli dati riservati, evita i droni di sicurezza e fuggi. Quanti livelli riesci a superare?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Fermate l'invasione aliena! Abbattete le ondate di invasori.",
     gamesArkanoidTitle: "Arkanoid",
@@ -2953,8 +2955,10 @@ module.exports = {
     gamesOutrunDesc: "Corsa contro il tempo! Evita il traffico e raggiungi il traguardo prima che scada.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Pilota la tua navicella in un campo di asteroidi. Sparali prima che ti colpiscano.",
+    gamesRockPaperScissorsTitle: "Carta Forbici Sasso",
+    gamesRockPaperScissorsDesc: "Carta, forbici, sasso contro una IA. Il migliore dei tre round vince.",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Carta, forbici, sasso contro una IA. Il migliore dei tre round vince.",
+    gamesTikTakToeDesc: "Tris classico contro l'IA. Allinea tre simboli per vincere.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Lancia una moneta e scommetti su testa o croce. Quanta fortuna hai?",
     games8BallTitle: "8Ball Pool",
@@ -3042,6 +3046,13 @@ module.exports = {
     calendarLeave: "Lascia Calendario",
     statsCalendar: "Calendari",
     statsCalendarDate: "Date calendario",
-    statsCalendarNote: "Note calendario"
+    statsCalendarNote: "Note calendario",
+    chatAccessDenied: "Non hai accesso a questa chat. Chiedi un invito per accedere al contenuto.",
+    padAccessDenied: "Non hai accesso a questo pad. Chiedi un invito per accedere al contenuto.",
+    contentAccessDenied: "Non hai accesso a questo contenuto.",
+    blockAccessRestricted: "Accesso limitato",
+    tribeContentAccessDenied: "Accesso Negato",
+    tribeContentAccessDeniedMsg: "Questo contenuto appartiene a una tribù. Devi essere membro per accedervi.",
+    tribeViewTribes: "Visualizza Tribù"
     }
 };

+ 13 - 2
src/client/assets/translations/oasis_pt.js

@@ -2943,6 +2943,8 @@ module.exports = {
     gamesCocolandDesc: "Um coco com olhos a saltar palmeiras e a colecionar ECOins.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Liga PUBs a habitantes através de validadores, lojas e acumuladores. Sobrevive à ameaça CBDC!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Infiltre a grade, colete dados confidenciais, evite os drones de segurança e escape. Quantos níveis consegue passar?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Pare a invasão alienígena! Destrua ondas de invasores.",
     gamesArkanoidTitle: "Arkanoid",
@@ -2953,8 +2955,10 @@ module.exports = {
     gamesOutrunDesc: "Corrida contra o tempo! Desvie do tráfego e chegue à meta antes do tempo acabar.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Pilote sua nave por um campo de asteroides mortais. Destrua-os antes que te atinjam.",
+    gamesRockPaperScissorsTitle: "Pedra Papel Tesoura",
+    gamesRockPaperScissorsDesc: "Pedra, papel ou tesoura contra uma IA. Melhor de três rodadas vence.",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Pedra, papel ou tesoura contra uma IA. Melhor de três rodadas vence.",
+    gamesTikTakToeDesc: "Jogo da Velha clássico contra a IA. Alinhe três para ganhar.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Lance uma moeda e aposte em cara ou coroa. Qual é a sua sorte?",
     games8BallTitle: "8Ball Pool",
@@ -3042,6 +3046,13 @@ module.exports = {
     calendarLeave: "Sair do Calendário",
     statsCalendar: "Calendários",
     statsCalendarDate: "Datas de calendário",
-    statsCalendarNote: "Notas de calendário"
+    statsCalendarNote: "Notas de calendário",
+    chatAccessDenied: "Você não tem acesso a este chat. Solicite um convite para acessar o conteúdo.",
+    padAccessDenied: "Você não tem acesso a este pad. Solicite um convite para acessar o conteúdo.",
+    contentAccessDenied: "Você não tem acesso a este conteúdo.",
+    blockAccessRestricted: "Acesso restrito",
+    tribeContentAccessDenied: "Acesso Negado",
+    tribeContentAccessDeniedMsg: "Este conteúdo pertence a uma tribo. Você deve ser membro para acessá-lo.",
+    tribeViewTribes: "Ver Tribos"
     }
 };

+ 13 - 2
src/client/assets/translations/oasis_ru.js

@@ -2905,6 +2905,8 @@ module.exports = {
     gamesCocolandDesc: "Кокос с глазами прыгает через пальмы и собирает ECOins.",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "Соединяй PUB'ы с жителями через валидаторы, магазины и аккумуляторы. Противостой угрозе CBDC!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "Проникни в сеть, собери секретные данные, избегай дронов безопасности и сбеги. Сколько уровней ты пройдёшь?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "Остановите вторжение пришельцев! Уничтожайте волны захватчиков.",
     gamesArkanoidTitle: "Arkanoid",
@@ -2915,8 +2917,10 @@ module.exports = {
     gamesOutrunDesc: "Гонка со временем! Уворачивайтесь от машин и доберитесь до финиша вовремя.",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "Пилотируйте корабль через астероидное поле. Уничтожайте их до столкновения.",
+    gamesRockPaperScissorsTitle: "Камень Ножницы Бумага",
+    gamesRockPaperScissorsDesc: "Камень, ножницы, бумага против ИИ. Лучший из трёх раундов побеждает.",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "Камень, ножницы, бумага против ИИ. Лучший из трёх раундов побеждает.",
+    gamesTikTakToeDesc: "Классические крестики-нолики против ИИ. Три в ряд — победа.",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "Подбросьте монету и угадайте орёл или решку. Насколько вы удачливы?",
     games8BallTitle: "8Ball Pool",
@@ -3004,6 +3008,13 @@ module.exports = {
     calendarLeave: "Покинуть календарь",
     statsCalendar: "Календари",
     statsCalendarDate: "Даты календаря",
-    statsCalendarNote: "Заметки календаря"
+    statsCalendarNote: "Заметки календаря",
+    chatAccessDenied: "У вас нет доступа к этому чату. Запросите приглашение для доступа к содержимому.",
+    padAccessDenied: "У вас нет доступа к этому блокноту. Запросите приглашение для доступа к содержимому.",
+    contentAccessDenied: "У вас нет доступа к этому содержимому.",
+    blockAccessRestricted: "Доступ ограничен",
+    tribeContentAccessDenied: "Доступ Запрещён",
+    tribeContentAccessDeniedMsg: "Этот контент принадлежит племени. Вы должны быть участником, чтобы получить к нему доступ.",
+    tribeViewTribes: "Посмотреть Племена"
     }
 };

+ 13 - 2
src/client/assets/translations/oasis_zh.js

@@ -2943,6 +2943,8 @@ module.exports = {
     gamesCocolandDesc: "一颗有眼睛的椰子跳过棕榈树,收集ECOins。",
     gamesTheFlowTitle: "ECOinflow",
     gamesTheFlowDesc: "通过验证者、商店和累积器将PUB连接到居民。抵御CBDC威胁!",
+    gamesNeonInfiltratorTitle: "Neon Infiltrator",
+    gamesNeonInfiltratorDesc: "渗透网格,收集机密数据,辺开安全无人机并逃脱。能通过多少层关?",
     gamesSpaceInvadersTitle: "Space Invaders",
     gamesSpaceInvadersDesc: "阻止外星人入侵!在入侵者抵达地面之前击落它们。",
     gamesArkanoidTitle: "Arkanoid",
@@ -2953,8 +2955,10 @@ module.exports = {
     gamesOutrunDesc: "与时间赛跑!躲避交通障碍,在时间用完前到达终点。",
     gamesAsteroidsTitle: "Asteroids",
     gamesAsteroidsDesc: "驾驶飞船穿越小行星带。在它们撞上你之前将其摧毁。",
+    gamesRockPaperScissorsTitle: "石头剪刀布",
+    gamesRockPaperScissorsDesc: "与AI对战石头、剪刀、布。三局两胜获胜。",
     gamesTikTakToeTitle: "TikTakToe",
-    gamesTikTakToeDesc: "与AI对战石头、剪刀、布。三局两胜获胜。",
+    gamesTikTakToeDesc: "与AI对战经典井字游戏。连成三个即可获胜。",
     gamesFlipFlopTitle: "FlipFlop",
     gamesFlipFlopDesc: "抛硬币,猜正面还是反面。你有多幸运?",
     games8BallTitle: "8Ball Pool",
@@ -3042,6 +3046,13 @@ module.exports = {
     calendarLeave: "离开日历",
     statsCalendar: "日历",
     statsCalendarDate: "日历日期",
-    statsCalendarNote: "日历笔记"
+    statsCalendarNote: "日历笔记",
+    chatAccessDenied: "您没有访问此聊天室的权限。请申请邀请以访问内容。",
+    padAccessDenied: "您没有访问此协作板的权限。请申请邀请以访问内容。",
+    contentAccessDenied: "您没有访问此内容的权限。",
+    blockAccessRestricted: "访问受限",
+    tribeContentAccessDenied: "访问被拒绝",
+    tribeContentAccessDeniedMsg: "此内容属于一个部落。您必须是成员才能访问它。",
+    tribeViewTribes: "查看部落"
     }
 };

+ 3 - 3
src/client/gui.js

@@ -73,7 +73,7 @@ const ensureConnection = (customConfig) => {
   return pendingConnection;
 };
 
-module.exports = ({ offline }) => {
+module.exports = ({ offline, port = 3000, host = 'localhost', isPublic = false }) => {
   const customConfig = JSON.parse(JSON.stringify(ssbConfig));
   if (offline === true) {
     lodash.set(customConfig, "conn.autostart", false);
@@ -89,7 +89,7 @@ module.exports = ({ offline }) => {
       return new Promise((resolve, reject) => {
         if (internalSSB) {
           const { printMetadata, colors } = require('../server/ssb_metadata');
-          printMetadata('OASIS GUI running at: http://localhost:3000', colors.yellow);
+          printMetadata('OASIS GUI', colors.yellow, port, host, offline, isPublic);
           return resolve(internalSSB);
         }
 
@@ -105,7 +105,7 @@ module.exports = ({ offline }) => {
             reject(new Error("Closing Oasis"));
           } else {
             const { printMetadata, colors } = require('../server/ssb_metadata');
-            printMetadata('OASIS GUI running at: http://localhost:3000', colors.yellow);
+            printMetadata('OASIS GUI', colors.yellow, port, host, offline, isPublic);
             resolve(ssb);
           }
         }).catch(reject);

+ 1 - 0
src/configs/media-favorites.json

@@ -1,6 +1,7 @@
 {
   "audios": [],
   "bookmarks": [],
+  "chats": [],
   "documents": [],
   "images": [],
   "maps": [],

+ 351 - 0
src/games/neoninfiltrator/index.html

@@ -0,0 +1,351 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
+<title>Neon Infiltrator</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; user-select: none; -webkit-tap-highlight-color: transparent; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#ui { display: flex; gap: 16px; padding: 6px 8px; font-size: 14px; color: #FFA500; flex-wrap: wrap; justify-content: center; width: 100%; }
+canvas { display: block; width: 100%; height: auto; max-width: 700px; border: 1px solid #333; image-rendering: crisp-edges; }
+#msg { font-size: 13px; color: #FFA500; margin: 3px; text-align: center; min-height: 18px; }
+#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 4px; }
+.dpad-row { display: flex; gap: 4px; }
+#dpad button { width: 50px; height: 50px; background: #1a0d00; border: 1px solid #FF6600; color: #FF6600; font-size: 20px; cursor: pointer; border-radius: 4px; }
+#dpad button:active { background: #3a1a00; }
+#controls { color: #666; font-size: 12px; text-align: center; margin-bottom: 3px; }
+#scoreSubmit { text-align: center; margin: 6px; }
+#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
+@media (min-width: 768px) { #dpad, #controls { display: none; } }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">NEON INFILTRATOR</span>
+</div>
+<div id="ui">
+  <span>DATA: <b id="dataEl">0/0</b></span>
+  <span>LEVEL: <b id="levelEl">1</b></span>
+  <span>SCORE: <b id="scoreEl">0</b></span>
+  <span>BEST: <b id="bestEl">-</b></span>
+  <span>ENEMIES: <b id="enemyEl">0</b></span>
+</div>
+<canvas id="c"></canvas>
+<div id="msg">Press any arrow key or tap to start</div>
+<div id="dpad">
+  <div class="dpad-row"><button id="btn-up">&#8593;</button></div>
+  <div class="dpad-row">
+    <button id="btn-left">&#8592;</button>
+    <button id="btn-down">&#8595;</button>
+    <button id="btn-right">&#8594;</button>
+  </div>
+</div>
+<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
+<div id="scoreSubmit" style="display:none">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="neoninfiltrator">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit">Submit Score to Hall of Fame</button>
+  </form>
+</div>
+<script>
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+
+const CELL = 28;
+let MAP_W = 0, MAP_H = 0;
+let walls = [], enemies = [], dataItems = [], exitPos = { x: -1, y: -1 };
+let player = { x: 1, y: 1 };
+let level = 1, dataCollected = 0, totalData = 0;
+let score = 0;
+let best = parseInt(localStorage.getItem('ni_best') || '-1');
+let gameState = 'idle';
+let lastPlayerMove = 0, lastEnemyStep = 0;
+const MOVE_DELAY = 140, ENEMY_INTERVAL = 500;
+const keys = {};
+
+function setMsg(txt) { document.getElementById('msg').textContent = txt; }
+
+function updateUI() {
+  document.getElementById('dataEl').textContent = dataCollected + '/' + totalData;
+  document.getElementById('levelEl').textContent = level;
+  document.getElementById('scoreEl').textContent = score;
+  document.getElementById('bestEl').textContent = best < 0 ? '-' : best;
+  document.getElementById('enemyEl').textContent = enemies.length;
+}
+
+function resizeCanvas() {
+  let bw = 18, bh = 14;
+  if (level >= 5) { bw = 22; bh = 18; }
+  else if (level >= 3) { bw = 20; bh = 16; }
+  MAP_W = bw; MAP_H = bh;
+  canvas.width = MAP_W * CELL;
+  canvas.height = MAP_H * CELL;
+}
+
+function generateLevel() {
+  resizeCanvas();
+  walls = Array(MAP_H).fill(null).map(() => Array(MAP_W).fill(false));
+  for (let i = 0; i < MAP_W; i++) { walls[0][i] = true; walls[MAP_H - 1][i] = true; }
+  for (let i = 0; i < MAP_H; i++) { walls[i][0] = true; walls[i][MAP_W - 1] = true; }
+
+  for (let i = 0; i < Math.floor(MAP_W * MAP_H / 7); i++) {
+    const x = 2 + Math.floor(Math.random() * (MAP_W - 4));
+    const y = 2 + Math.floor(Math.random() * (MAP_H - 4));
+    if (!walls[y][x] && !(Math.abs(x - player.x) < 2 && Math.abs(y - player.y) < 2)) {
+      if (Math.random() < 0.55) walls[y][x] = true;
+    }
+  }
+  for (let i = 0; i < MAP_W * 2; i++) {
+    const x = 1 + Math.floor(Math.random() * (MAP_W - 2));
+    const y = 1 + Math.floor(Math.random() * (MAP_H - 2));
+    if (walls[y][x] && x > 1 && x < MAP_W - 2 && y > 1 && y < MAP_H - 2) {
+      if (Math.random() < 0.5) walls[y][x] = false;
+    }
+  }
+
+  player.x = 1; player.y = 1;
+  if (walls[player.y][player.x]) {
+    outer: for (let y = 1; y < MAP_H - 1; y++) {
+      for (let x = 1; x < MAP_W - 1; x++) {
+        if (!walls[y][x]) { player.x = x; player.y = y; break outer; }
+      }
+    }
+  }
+
+  exitPos = { x: MAP_W - 2, y: MAP_H - 2 };
+  for (let t = 0; t < 200; t++) {
+    const ex = MAP_W - 2 - Math.floor(Math.random() * 3);
+    const ey = MAP_H - 2 - Math.floor(Math.random() * 3);
+    if (ex >= 1 && ey >= 1 && !walls[ey][ex] && (Math.abs(ex - player.x) + Math.abs(ey - player.y)) > 6) {
+      exitPos = { x: ex, y: ey }; break;
+    }
+  }
+
+  totalData = Math.min(12, 3 + Math.floor(level / 1.5));
+  dataItems = [];
+  for (let i = 0; i < totalData; i++) {
+    let placed = false;
+    for (let t = 0; t < 100; t++) {
+      const dx = 1 + Math.floor(Math.random() * (MAP_W - 2));
+      const dy = 1 + Math.floor(Math.random() * (MAP_H - 2));
+      if (!walls[dy][dx] && !(dx === player.x && dy === player.y) && !(dx === exitPos.x && dy === exitPos.y) && !dataItems.some(d => d.x === dx && d.y === dy)) {
+        dataItems.push({ x: dx, y: dy, collected: false }); placed = true; break;
+      }
+    }
+    if (!placed) {
+      outer2: for (let y = 1; y < MAP_H - 1; y++) {
+        for (let x = 1; x < MAP_W - 1; x++) {
+          if (!walls[y][x] && !(x === player.x && y === player.y) && !(x === exitPos.x && y === exitPos.y) && !dataItems.some(d => d.x === x && d.y === y)) {
+            dataItems.push({ x, y, collected: false }); break outer2;
+          }
+        }
+      }
+    }
+  }
+  dataCollected = 0;
+
+  const enemyCount = Math.min(6, 1 + Math.floor(level / 2));
+  enemies = [];
+  for (let i = 0; i < enemyCount; i++) {
+    let placed = false;
+    for (let t = 0; t < 150; t++) {
+      const ex = 2 + Math.floor(Math.random() * (MAP_W - 4));
+      const ey = 2 + Math.floor(Math.random() * (MAP_H - 4));
+      if (!walls[ey][ex] && (Math.abs(ex - player.x) + Math.abs(ey - player.y)) > 4 && !(ex === exitPos.x && ey === exitPos.y) && !dataItems.some(d => d.x === ex && d.y === ey)) {
+        enemies.push({ x: ex, y: ey }); placed = true; break;
+      }
+    }
+    if (!placed) {
+      outer3: for (let y = 2; y < MAP_H - 2; y++) {
+        for (let x = 2; x < MAP_W - 2; x++) {
+          if (!walls[y][x] && (Math.abs(x - player.x) + Math.abs(y - player.y)) > 3 && !enemies.some(e => e.x === x && e.y === y)) {
+            enemies.push({ x, y }); break outer3;
+          }
+        }
+      }
+    }
+  }
+  updateUI();
+  setMsg(`Level ${level} — Collect ${totalData} data items and reach the exit`);
+}
+
+function checkEnemyCollision() {
+  for (const e of enemies) {
+    if (e.x === player.x && e.y === player.y) { endGame(); return true; }
+  }
+  return false;
+}
+
+function tryMove(dx, dy) {
+  if (gameState !== 'play') return;
+  const nx = player.x + dx, ny = player.y + dy;
+  if (nx < 0 || nx >= MAP_W || ny < 0 || ny >= MAP_H || walls[ny][nx]) return;
+  player.x = nx; player.y = ny;
+
+  for (const d of dataItems) {
+    if (!d.collected && d.x === player.x && d.y === player.y) {
+      d.collected = true; dataCollected++; score += 100;
+      updateUI(); setMsg(`Data collected! (${dataCollected}/${totalData})`); break;
+    }
+  }
+
+  if (dataCollected === totalData && exitPos.x === player.x && exitPos.y === player.y) {
+    const bonus = level * 50;
+    score += bonus;
+    updateUI();
+    setMsg(`Level ${level} complete! +${bonus} bonus. Advancing...`);
+    gameState = 'transit';
+    setTimeout(() => { level++; generateLevel(); gameState = 'play'; lastPlayerMove = performance.now(); lastEnemyStep = performance.now(); }, 1400);
+    return;
+  }
+
+  checkEnemyCollision();
+}
+
+function moveEnemies() {
+  if (gameState !== 'play') return;
+  for (const e of enemies) {
+    let dx = 0, dy = 0;
+    if (Math.random() < 0.6) {
+      if (Math.abs(e.x - player.x) > Math.abs(e.y - player.y)) dx = e.x > player.x ? -1 : 1;
+      else dy = e.y > player.y ? -1 : 1;
+    } else {
+      const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
+      [dx, dy] = dirs[Math.floor(Math.random() * 4)];
+    }
+    const nx = e.x + dx, ny = e.y + dy;
+    if (nx >= 0 && nx < MAP_W && ny >= 0 && ny < MAP_H && !walls[ny][nx] && !(nx === exitPos.x && ny === exitPos.y)) {
+      e.x = nx; e.y = ny;
+    } else {
+      for (const [sx, sy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+        const bx = e.x + sx, by = e.y + sy;
+        if (bx >= 0 && bx < MAP_W && by >= 0 && by < MAP_H && !walls[by][bx] && !(bx === exitPos.x && by === exitPos.y)) {
+          e.x = bx; e.y = by; break;
+        }
+      }
+    }
+  }
+  checkEnemyCollision();
+  updateUI();
+}
+
+function endGame() {
+  gameState = 'over';
+  if (best < 0 || score > best) { best = score; localStorage.setItem('ni_best', best); }
+  updateUI();
+  setMsg(`DETECTED! Score: ${score}. Press SPACE for new game.`);
+  document.getElementById('scoreInput').value = score;
+  document.getElementById('scoreSubmit').style.display = 'block';
+}
+
+function newGame() {
+  score = 0; level = 1;
+  document.getElementById('scoreSubmit').style.display = 'none';
+  generateLevel();
+  gameState = 'play';
+  lastPlayerMove = performance.now();
+  lastEnemyStep = performance.now();
+}
+
+function draw() {
+  ctx.clearRect(0, 0, canvas.width, canvas.height);
+  ctx.fillStyle = '#050505';
+  ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+  ctx.strokeStyle = '#1a0d00'; ctx.lineWidth = 0.6;
+  for (let i = 0; i <= MAP_W; i++) {
+    ctx.beginPath(); ctx.moveTo(i * CELL, 0); ctx.lineTo(i * CELL, canvas.height); ctx.stroke();
+  }
+  for (let i = 0; i <= MAP_H; i++) {
+    ctx.beginPath(); ctx.moveTo(0, i * CELL); ctx.lineTo(canvas.width, i * CELL); ctx.stroke();
+  }
+
+  for (let y = 0; y < MAP_H; y++) {
+    for (let x = 0; x < MAP_W; x++) {
+      if (walls[y][x]) {
+        ctx.fillStyle = '#1a0a05';
+        ctx.fillRect(x * CELL, y * CELL, CELL - 0.5, CELL - 0.5);
+        ctx.strokeStyle = '#FF6600'; ctx.lineWidth = 1;
+        ctx.strokeRect(x * CELL, y * CELL, CELL - 0.5, CELL - 0.5);
+      }
+    }
+  }
+
+  for (const d of dataItems) {
+    if (!d.collected) {
+      ctx.fillStyle = '#FFB347'; ctx.shadowBlur = 6; ctx.shadowColor = '#FF6600';
+      ctx.beginPath(); ctx.arc(d.x * CELL + CELL / 2, d.y * CELL + CELL / 2, CELL * 0.25, 0, Math.PI * 2); ctx.fill();
+      ctx.fillStyle = '#FF884D';
+      ctx.beginPath(); ctx.arc(d.x * CELL + CELL / 2, d.y * CELL + CELL / 2, CELL * 0.12, 0, Math.PI * 2); ctx.fill();
+      ctx.shadowBlur = 0;
+    }
+  }
+
+  ctx.fillStyle = '#FFA500'; ctx.globalAlpha = 0.7;
+  ctx.fillRect(exitPos.x * CELL + 4, exitPos.y * CELL + 4, CELL - 8, CELL - 8);
+  ctx.globalAlpha = 1; ctx.fillStyle = '#FFA500';
+  ctx.font = `${CELL - 6}px monospace`;
+  ctx.fillText('\u25C8', exitPos.x * CELL + CELL * 0.28, exitPos.y * CELL + CELL * 0.78);
+
+  if (dataCollected === totalData && gameState === 'play') {
+    ctx.fillStyle = '#FFB347'; ctx.shadowBlur = 4; ctx.shadowColor = '#FF6600';
+    ctx.font = 'bold 14px monospace'; ctx.textAlign = 'center';
+    ctx.fillText('\u2192 EXIT AVAILABLE \u2190', canvas.width / 2, 20);
+    ctx.shadowBlur = 0; ctx.textAlign = 'left';
+  }
+
+  for (const e of enemies) {
+    ctx.fillStyle = '#FF4400'; ctx.shadowBlur = 6; ctx.shadowColor = '#FF2200';
+    ctx.fillRect(e.x * CELL + 4, e.y * CELL + 4, CELL - 8, CELL - 8);
+    ctx.shadowBlur = 0;
+  }
+
+  ctx.fillStyle = '#FF6600'; ctx.shadowBlur = 8; ctx.shadowColor = '#FF6600';
+  ctx.fillRect(player.x * CELL + 6, player.y * CELL + 6, CELL - 12, CELL - 12);
+  ctx.fillStyle = '#FFB347';
+  ctx.fillRect(player.x * CELL + 10, player.y * CELL + 10, 5, 5);
+  ctx.shadowBlur = 0;
+}
+
+function gameLoop(now) {
+  if (gameState === 'play') {
+    let dx = 0, dy = 0;
+    if (keys.ArrowUp || keys.w) dy = -1;
+    if (keys.ArrowDown || keys.s) dy = 1;
+    if (keys.ArrowLeft || keys.a) dx = -1;
+    if (keys.ArrowRight || keys.d) dx = 1;
+    if ((dx !== 0 || dy !== 0) && now - lastPlayerMove >= MOVE_DELAY) {
+      tryMove(dx, dy); lastPlayerMove = now;
+    }
+    if (now - lastEnemyStep >= ENEMY_INTERVAL) { moveEnemies(); lastEnemyStep = now; }
+  }
+  draw();
+  requestAnimationFrame(gameLoop);
+}
+
+document.addEventListener('keydown', e => {
+  const key = e.key;
+  if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'w', 's', 'a', 'd', ' '].includes(key)) e.preventDefault();
+  if (key === ' ') { if (gameState === 'idle' || gameState === 'over') { newGame(); } return; }
+  keys[key] = true;
+  if (gameState === 'idle') newGame();
+});
+document.addEventListener('keyup', e => { keys[e.key] = false; });
+
+const dpadStart = () => { if (gameState === 'idle' || gameState === 'over') newGame(); };
+document.getElementById('btn-up').addEventListener('click', () => { dpadStart(); tryMove(0, -1); });
+document.getElementById('btn-down').addEventListener('click', () => { dpadStart(); tryMove(0, 1); });
+document.getElementById('btn-left').addEventListener('click', () => { dpadStart(); tryMove(-1, 0); });
+document.getElementById('btn-right').addEventListener('click', () => { dpadStart(); tryMove(1, 0); });
+
+generateLevel();
+requestAnimationFrame(gameLoop);
+</script>
+</body>
+</html>

+ 73 - 0
src/games/neoninfiltrator/thumbnail.svg

@@ -0,0 +1,73 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#050505">
+  <rect width="400" height="220" fill="#050505"/>
+
+  <line x1="28" y1="10" x2="28" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="56" y1="10" x2="56" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="84" y1="10" x2="84" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="112" y1="10" x2="112" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="140" y1="10" x2="140" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="168" y1="10" x2="168" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="196" y1="10" x2="196" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="224" y1="10" x2="224" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="252" y1="10" x2="252" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="280" y1="10" x2="280" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="308" y1="10" x2="308" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="336" y1="10" x2="336" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="364" y1="10" x2="364" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="0" y1="38" x2="392" y2="38" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="0" y1="66" x2="392" y2="66" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="0" y1="94" x2="392" y2="94" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="0" y1="122" x2="392" y2="122" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="0" y1="150" x2="392" y2="150" stroke="#1a0d00" stroke-width="0.6"/>
+  <line x1="0" y1="178" x2="392" y2="178" stroke="#1a0d00" stroke-width="0.6"/>
+
+  <rect x="0" y="10" width="392" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="0" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="0" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="0" y="92.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="0" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="0" y="147.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="364.5" y="37.5" width="27.5" height="140" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="56" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="112" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="140" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="196" y="92.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="224" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="252" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="280" y="37.5" width="27.5" height="55" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="308" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="168" y="147.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+  <rect x="84" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
+
+  <circle cx="71" cy="51" r="6" fill="#FFB347">
+    <animate attributeName="opacity" values="1;0.4;1" dur="1.2s" repeatCount="indefinite"/>
+  </circle>
+  <circle cx="71" cy="51" r="3" fill="#FF884D"/>
+
+  <circle cx="183" cy="107" r="6" fill="#FFB347">
+    <animate attributeName="opacity" values="0.4;1;0.4" dur="1.2s" repeatCount="indefinite"/>
+  </circle>
+  <circle cx="183" cy="107" r="3" fill="#FF884D"/>
+
+  <circle cx="323" cy="79" r="6" fill="#FFB347">
+    <animate attributeName="opacity" values="1;0.4;1" dur="0.9s" repeatCount="indefinite"/>
+  </circle>
+  <circle cx="323" cy="79" r="3" fill="#FF884D"/>
+
+  <rect x="212" y="129" width="12" height="12" fill="#FFA500" opacity="0.7"/>
+  <text x="218" y="140" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">&#9672;</text>
+
+  <rect x="237" y="46" width="20" height="20" fill="#FF4400">
+    <animate attributeName="opacity" values="1;0.6;1" dur="0.8s" repeatCount="indefinite"/>
+  </rect>
+  <rect x="353" y="101" width="20" height="20" fill="#FF4400">
+    <animate attributeName="opacity" values="0.6;1;0.6" dur="0.8s" repeatCount="indefinite"/>
+  </rect>
+
+  <rect x="35" y="46" width="18" height="18" fill="#FF6600">
+    <animate attributeName="opacity" values="1;0.7;1" dur="1s" repeatCount="indefinite"/>
+  </rect>
+  <rect x="39" y="50" width="7" height="7" fill="#FFB347"/>
+
+  <text x="200" y="212" font-family="'Courier New', monospace" font-size="13" fill="#FFA500" text-anchor="middle" font-weight="bold" letter-spacing="3">NEON INFILTRATOR</text>
+</svg>

+ 182 - 0
src/games/rockpaperscissors/index.html

@@ -0,0 +1,182 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Rock Paper Scissors</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html, body { overflow: hidden; }
+body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; min-height: 100vh; }
+#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
+#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
+#topbar a:hover { text-decoration: underline; }
+h1 { color: #FFA500; font-size: 1.4rem; margin: 18px 0 6px; }
+#score-bar { display: flex; gap: 32px; font-size: 15px; color: #aaa; margin-bottom: 14px; }
+#score-bar span b { color: #FFA500; }
+#arena { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; max-width: 480px; padding: 0 12px; }
+#choices { display: flex; gap: 16px; }
+.choice-btn { font-size: 3rem; background: #111; border: 2px solid #333; border-radius: 12px; cursor: pointer; padding: 14px 20px; transition: border-color 0.15s, background 0.15s; line-height: 1; }
+.choice-btn:hover { border-color: #FFA500; background: #1a1a00; }
+.choice-btn.selected { border-color: #FFA500; background: #2a1a00; }
+#battle { display: flex; align-items: center; gap: 24px; font-size: 2.2rem; min-height: 72px; }
+#vs { font-size: 1rem; color: #555; }
+#result { font-size: 1.3rem; min-height: 32px; text-align: center; }
+.win { color: #4CAF50; } .lose { color: #e74c3c; } .draw { color: #FFA500; }
+#rounds { display: flex; gap: 8px; margin-top: 4px; }
+.round-dot { width: 14px; height: 14px; border-radius: 50%; background: #222; border: 2px solid #444; }
+.round-dot.win { background: #4CAF50; border-color: #4CAF50; }
+.round-dot.lose { background: #e74c3c; border-color: #e74c3c; }
+.round-dot.draw { background: #FFA500; border-color: #FFA500; }
+#play-btn { margin-top: 10px; padding: 10px 32px; font-family: monospace; font-size: 1rem; background: #FFA500; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
+#play-btn:hover { background: #ffb700; }
+#play-btn:disabled { background: #333; color: #666; cursor: default; }
+#final { font-size: 1.4rem; min-height: 36px; text-align: center; margin-top: 8px; }
+#hint { color: #555; font-size: 13px; margin-top: 4px; }
+</style>
+</head>
+<body>
+<div id="topbar">
+  <a href="/games" target="_top">&#8592; Back to Games</a>
+  <span style="color:#FFA500;font-weight:bold">ROCK PAPER SCISSORS</span>
+</div>
+<h1>Rock &middot; Paper &middot; Scissors</h1>
+<div id="score-bar">
+  <span>YOU: <b id="playerWins">0</b></span>
+  <span>AI: <b id="aiWins">0</b></span>
+  <span>DRAWS: <b id="draws">0</b></span>
+</div>
+<div id="arena">
+  <div id="choices">
+    <button class="choice-btn" data-choice="rock" title="Rock">&#9994;</button>
+    <button class="choice-btn" data-choice="paper" title="Paper">&#9995;</button>
+    <button class="choice-btn" data-choice="scissors" title="Scissors">✌️</button>
+  </div>
+  <div id="battle">
+    <span id="playerEmoji">?</span>
+    <span id="vs">VS</span>
+    <span id="aiEmoji">?</span>
+  </div>
+  <div id="result">&nbsp;</div>
+  <div id="rounds"></div>
+  <button id="play-btn" disabled>Choose a move!</button>
+  <div id="final">&nbsp;</div>
+  <div id="hint">Best of 5 rounds</div>
+  <div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
+    <form method="POST" action="/games/submit-score" target="_top">
+      <input type="hidden" name="game" value="rockpaperscissors">
+      <input type="hidden" id="scoreInput" name="score" value="0">
+      <button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
+    </form>
+  </div>
+</div>
+<script>
+const EMOJI = { rock: '\u270A', paper: '\u270B', scissors: '✌️' };
+const BEATS = { rock: 'scissors', scissors: 'paper', paper: 'rock' };
+const NAMES = { rock: 'Rock', paper: 'Paper', scissors: 'Scissors' };
+
+let playerWins = 0, aiWins = 0, draws = 0;
+let roundHistory = [];
+let chosen = null;
+let gameOver = false;
+let streak = 0, bestStreak = 0;
+const MAX_ROUNDS = 5;
+
+const playerWinsEl = document.getElementById('playerWins');
+const aiWinsEl = document.getElementById('aiWins');
+const drawsEl = document.getElementById('draws');
+const playerEmojiEl = document.getElementById('playerEmoji');
+const aiEmojiEl = document.getElementById('aiEmoji');
+const resultEl = document.getElementById('result');
+const roundsEl = document.getElementById('rounds');
+const playBtn = document.getElementById('play-btn');
+const finalEl = document.getElementById('final');
+
+function updateRounds() {
+  while (roundsEl.firstChild) roundsEl.removeChild(roundsEl.firstChild);
+  for (let i = 0; i < MAX_ROUNDS; i++) {
+    const dot = document.createElement('div');
+    dot.className = 'round-dot' + (roundHistory[i] ? ' ' + roundHistory[i] : '');
+    roundsEl.appendChild(dot);
+  }
+}
+
+document.querySelectorAll('.choice-btn').forEach(btn => {
+  btn.addEventListener('click', () => {
+    if (gameOver) return;
+    document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
+    btn.classList.add('selected');
+    chosen = btn.dataset.choice;
+    playBtn.disabled = false;
+    playBtn.textContent = 'PLAY!';
+  });
+});
+
+function onPlay() {
+  if (!chosen || gameOver) return;
+  const choices = ['rock', 'paper', 'scissors'];
+  const aiChoice = choices[Math.floor(Math.random() * 3)];
+
+  playerEmojiEl.textContent = EMOJI[chosen];
+  aiEmojiEl.textContent = EMOJI[aiChoice];
+
+  let outcome, outcomeClass;
+  if (chosen === aiChoice) {
+    outcome = 'DRAW!'; outcomeClass = 'draw';
+    draws++;
+    roundHistory.push('draw');
+    drawsEl.textContent = draws;
+  } else if (BEATS[chosen] === aiChoice) {
+    outcome = NAMES[chosen] + ' beats ' + NAMES[aiChoice] + ' \u2014 YOU WIN!'; outcomeClass = 'win';
+    playerWins++; streak++;
+    if (streak > bestStreak) bestStreak = streak;
+    roundHistory.push('win');
+    playerWinsEl.textContent = playerWins;
+  } else {
+    outcome = NAMES[aiChoice] + ' beats ' + NAMES[chosen] + ' \u2014 AI WINS!'; outcomeClass = 'lose';
+    aiWins++; streak = 0;
+    roundHistory.push('lose');
+    aiWinsEl.textContent = aiWins;
+  }
+
+  resultEl.className = outcomeClass;
+  resultEl.textContent = outcome;
+  updateRounds();
+
+  const total = playerWins + aiWins + draws;
+  if (playerWins >= 3 || aiWins >= 3 || total >= MAX_ROUNDS) {
+    gameOver = true;
+    if (playerWins > aiWins) { finalEl.className = 'win'; finalEl.textContent = 'YOU WIN THE MATCH! Best streak: ' + bestStreak; document.getElementById('scoreInput').value = bestStreak; document.getElementById('scoreSubmit').style.display = 'block'; }
+    else if (aiWins > playerWins) { finalEl.className = 'lose'; finalEl.textContent = 'AI WINS THE MATCH!'; }
+    else { finalEl.className = 'draw'; finalEl.textContent = "IT'S A TIE!"; }
+    playBtn.textContent = 'New Game';
+    playBtn.disabled = false;
+    playBtn.onclick = resetGame;
+  } else {
+    chosen = null;
+    document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
+    playBtn.disabled = true;
+    playBtn.textContent = 'Choose a move!';
+  }
+}
+
+playBtn.addEventListener('click', onPlay);
+
+function resetGame() {
+  playerWins = 0; aiWins = 0; draws = 0; roundHistory = []; chosen = null; gameOver = false; streak = 0; bestStreak = 0;
+  playerWinsEl.textContent = '0'; aiWinsEl.textContent = '0'; drawsEl.textContent = '0';
+  playerEmojiEl.textContent = '?'; aiEmojiEl.textContent = '?';
+  resultEl.textContent = '\u00a0'; resultEl.className = '';
+  finalEl.textContent = '\u00a0'; finalEl.className = '';
+  document.getElementById('scoreSubmit').style.display = 'none';
+  document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
+  playBtn.disabled = true;
+  playBtn.textContent = 'Choose a move!';
+  playBtn.onclick = onPlay;
+  updateRounds();
+}
+
+updateRounds();
+</script>
+</body>
+</html>

+ 17 - 0
src/games/rockpaperscissors/thumbnail.svg

@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
+  <rect width="400" height="220" fill="#000"/>
+  <rect x="30" y="55" width="90" height="100" rx="14" fill="#111" stroke="#FFA500" stroke-width="2"/>
+  <rect x="42" y="38" width="66" height="26" rx="10" fill="#111" stroke="#FFA500" stroke-width="2"/>
+  <rect x="42" y="56" width="22" height="10" rx="4" fill="#FFA500" opacity="0.3"/>
+  <rect x="68" y="56" width="22" height="10" rx="4" fill="#FFA500" opacity="0.3"/>
+  <ellipse cx="75" cy="105" rx="28" ry="34" fill="#FFA500" opacity="0.15"/>
+  <text x="75" y="115" font-family="monospace" font-size="30" fill="#FFA500" text-anchor="middle">✊</text>
+  <text x="75" y="170" font-family="monospace" font-size="10" fill="#FFA500" text-anchor="middle">ROCK</text>
+  <rect x="155" y="40" width="90" height="115" rx="14" fill="#111" stroke="#4CAF50" stroke-width="2"/>
+  <text x="200" y="115" font-family="monospace" font-size="30" fill="#4CAF50" text-anchor="middle">✋</text>
+  <text x="200" y="170" font-family="monospace" font-size="10" fill="#4CAF50" text-anchor="middle">PAPER</text>
+  <rect x="280" y="55" width="90" height="100" rx="14" fill="#111" stroke="#FFA500" stroke-width="2"/>
+  <text x="325" y="115" font-family="monospace" font-size="30" fill="#FFA500" text-anchor="middle">✌️</text>
+  <text x="325" y="170" font-family="monospace" font-size="10" fill="#FFA500" text-anchor="middle">SCISSORS</text>
+  <text x="200" y="210" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">Rock · Paper · Scissors</text>
+</svg>

+ 144 - 136
src/games/tiktaktoe/index.html

@@ -3,7 +3,7 @@
 <head>
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>TikTakToe — Rock Paper Scissors</title>
+<title>TikTakToe</title>
 <style>
 * { margin: 0; padding: 0; box-sizing: border-box; }
 html, body { overflow: hidden; }
@@ -14,169 +14,177 @@ body { background: #000; color: #eee; font-family: monospace; display: flex; fle
 h1 { color: #FFA500; font-size: 1.4rem; margin: 18px 0 6px; }
 #score-bar { display: flex; gap: 32px; font-size: 15px; color: #aaa; margin-bottom: 14px; }
 #score-bar span b { color: #FFA500; }
-#arena { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; max-width: 480px; padding: 0 12px; }
-#choices { display: flex; gap: 16px; }
-.choice-btn { font-size: 3rem; background: #111; border: 2px solid #333; border-radius: 12px; cursor: pointer; padding: 14px 20px; transition: border-color 0.15s, background 0.15s; line-height: 1; }
-.choice-btn:hover { border-color: #FFA500; background: #1a1a00; }
-.choice-btn.selected { border-color: #FFA500; background: #2a1a00; }
-#battle { display: flex; align-items: center; gap: 24px; font-size: 2.2rem; min-height: 72px; }
-#vs { font-size: 1rem; color: #555; }
-#result { font-size: 1.3rem; min-height: 32px; text-align: center; }
-.win { color: #4CAF50; } .lose { color: #e74c3c; } .draw { color: #FFA500; }
-#rounds { display: flex; gap: 8px; margin-top: 4px; }
-.round-dot { width: 14px; height: 14px; border-radius: 50%; background: #222; border: 2px solid #444; }
-.round-dot.win { background: #4CAF50; border-color: #4CAF50; }
-.round-dot.lose { background: #e74c3c; border-color: #e74c3c; }
-.round-dot.draw { background: #FFA500; border-color: #FFA500; }
-#play-btn { margin-top: 10px; padding: 10px 32px; font-family: monospace; font-size: 1rem; background: #FFA500; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
-#play-btn:hover { background: #ffb700; }
-#play-btn:disabled { background: #333; color: #666; cursor: default; }
-#final { font-size: 1.4rem; min-height: 36px; text-align: center; margin-top: 8px; }
-#hint { color: #555; font-size: 13px; margin-top: 4px; }
+#board { display: grid; grid-template-columns: repeat(3, 90px); grid-template-rows: repeat(3, 90px); gap: 6px; margin: 10px 0; }
+.cell { width: 90px; height: 90px; background: #111; border: 2px solid #333; border-radius: 8px; font-size: 2.6rem; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
+.cell:hover:not(.taken) { border-color: #FFA500; background: #1a1a00; }
+.cell.x { color: #FFA500; cursor: default; }
+.cell.o { color: #4CAF50; cursor: default; }
+.cell.taken { cursor: default; }
+.cell.win-cell { border-color: #FFA500; background: #1a1200; }
+#status { font-size: 1.2rem; min-height: 32px; text-align: center; margin: 8px 0; }
+.win { color: #4CAF50; } .lose { color: #e74c3c; } .draw { color: #FFA500; } .thinking { color: #555; }
+#new-btn { margin-top: 10px; padding: 10px 32px; font-family: monospace; font-size: 1rem; background: #FFA500; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
+#new-btn:hover { background: #ffb700; }
+#scoreSubmit { display: none; text-align: center; margin: 8px; }
+#scoreSubmit form button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
 </style>
 </head>
 <body>
 <div id="topbar">
   <a href="/games" target="_top">&#8592; Back to Games</a>
-  <span style="color:#FFA500;font-weight:bold">ROCK PAPER SCISSORS</span>
+  <span style="color:#FFA500;font-weight:bold">TIKTAKTOE</span>
 </div>
-<h1>Rock &middot; Paper &middot; Scissors</h1>
+<h1>Tic &middot; Tac &middot; Toe</h1>
 <div id="score-bar">
-  <span>YOU: <b id="playerWins">0</b></span>
-  <span>AI: <b id="aiWins">0</b></span>
-  <span>DRAWS: <b id="draws">0</b></span>
+  <span>YOU (X): <b id="winsEl">0</b></span>
+  <span>AI (O): <b id="lossesEl">0</b></span>
+  <span>DRAWS: <b id="drawsEl">0</b></span>
 </div>
-<div id="arena">
-  <div id="choices">
-    <button class="choice-btn" data-choice="rock" title="Rock">&#9994;</button>
-    <button class="choice-btn" data-choice="paper" title="Paper">&#9995;</button>
-    <button class="choice-btn" data-choice="scissors" title="Scissors">✌️</button>
-  </div>
-  <div id="battle">
-    <span id="playerEmoji">?</span>
-    <span id="vs">VS</span>
-    <span id="aiEmoji">?</span>
-  </div>
-  <div id="result">&nbsp;</div>
-  <div id="rounds"></div>
-  <button id="play-btn" disabled>Choose a move!</button>
-  <div id="final">&nbsp;</div>
-  <div id="hint">Best of 5 rounds</div>
-  <div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
-    <form method="POST" action="/games/submit-score" target="_top">
-      <input type="hidden" name="game" value="tiktaktoe">
-      <input type="hidden" id="scoreInput" name="score" value="0">
-      <button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
-    </form>
-  </div>
+<div id="board"></div>
+<div id="status">&nbsp;</div>
+<button id="new-btn">New Game</button>
+<div id="scoreSubmit">
+  <form method="POST" action="/games/submit-score" target="_top">
+    <input type="hidden" name="game" value="tiktaktoe">
+    <input type="hidden" id="scoreInput" name="score" value="0">
+    <button type="submit">Submit Score to Hall of Fame</button>
+  </form>
 </div>
 <script>
-const EMOJI = { rock: '\u270A', paper: '\u270B', scissors: '✌️' };
-const BEATS = { rock: 'scissors', scissors: 'paper', paper: 'rock' };
-const NAMES = { rock: 'Rock', paper: 'Paper', scissors: 'Scissors' };
+const LINES = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];
+let board, gameOver, wins, losses, draws;
 
-let playerWins = 0, aiWins = 0, draws = 0;
-let roundHistory = [];
-let chosen = null;
-let gameOver = false;
-let streak = 0, bestStreak = 0;
-const MAX_ROUNDS = 5;
+function init() {
+  wins = 0; losses = 0; draws = 0;
+  newGame();
+}
 
-const playerWinsEl = document.getElementById('playerWins');
-const aiWinsEl = document.getElementById('aiWins');
-const drawsEl = document.getElementById('draws');
-const playerEmojiEl = document.getElementById('playerEmoji');
-const aiEmojiEl = document.getElementById('aiEmoji');
-const resultEl = document.getElementById('result');
-const roundsEl = document.getElementById('rounds');
-const playBtn = document.getElementById('play-btn');
-const finalEl = document.getElementById('final');
+function newGame() {
+  board = Array(9).fill(null);
+  gameOver = false;
+  document.getElementById('scoreSubmit').style.display = 'none';
+  setStatus('\u00a0', '');
+  renderBoard();
+}
 
-function updateRounds() {
-  while (roundsEl.firstChild) roundsEl.removeChild(roundsEl.firstChild);
-  for (let i = 0; i < MAX_ROUNDS; i++) {
-    const dot = document.createElement('div');
-    dot.className = 'round-dot' + (roundHistory[i] ? ' ' + roundHistory[i] : '');
-    roundsEl.appendChild(dot);
+function renderBoard() {
+  const el = document.getElementById('board');
+  while (el.firstChild) el.removeChild(el.firstChild);
+  const winner = checkWinner(board);
+  for (let i = 0; i < 9; i++) {
+    const cell = document.createElement('div');
+    cell.className = 'cell' + (board[i] ? ' taken ' + board[i] : '');
+    if (winner && winner.line.includes(i)) cell.className += ' win-cell';
+    if (board[i]) {
+      const t = document.createTextNode(board[i] === 'x' ? 'X' : 'O');
+      cell.appendChild(t);
+    }
+    const idx = i;
+    cell.addEventListener('click', () => playerMove(idx));
+    el.appendChild(cell);
   }
 }
 
-document.querySelectorAll('.choice-btn').forEach(btn => {
-  btn.addEventListener('click', () => {
-    if (gameOver) return;
-    document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
-    btn.classList.add('selected');
-    chosen = btn.dataset.choice;
-    playBtn.disabled = false;
-    playBtn.textContent = 'PLAY!';
-  });
-});
+function setStatus(msg, cls) {
+  const el = document.getElementById('status');
+  el.className = cls;
+  el.textContent = msg;
+}
 
-function onPlay() {
-  if (!chosen || gameOver) return;
-  const choices = ['rock', 'paper', 'scissors'];
-  const aiChoice = choices[Math.floor(Math.random() * 3)];
+function checkWinner(b) {
+  for (const [a, c, d] of LINES) {
+    if (b[a] && b[a] === b[c] && b[a] === b[d]) return { winner: b[a], line: [a, c, d] };
+  }
+  return null;
+}
 
-  playerEmojiEl.textContent = EMOJI[chosen];
-  aiEmojiEl.textContent = EMOJI[aiChoice];
+function playerMove(idx) {
+  if (gameOver || board[idx]) return;
+  board[idx] = 'x';
+  renderBoard();
+  const res = checkWinner(board);
+  if (res) { endGame('win'); return; }
+  if (board.every(c => c)) { endGame('draw'); return; }
+  setStatus('Thinking...', 'thinking');
+  setTimeout(aiMove, 200);
+}
 
-  let outcome, outcomeClass;
-  if (chosen === aiChoice) {
-    outcome = 'DRAW!'; outcomeClass = 'draw';
-    draws++;
-    roundHistory.push('draw');
-    drawsEl.textContent = draws;
-  } else if (BEATS[chosen] === aiChoice) {
-    outcome = NAMES[chosen] + ' beats ' + NAMES[aiChoice] + ' \u2014 YOU WIN!'; outcomeClass = 'win';
-    playerWins++; streak++;
-    if (streak > bestStreak) bestStreak = streak;
-    roundHistory.push('win');
-    playerWinsEl.textContent = playerWins;
+function aiMove() {
+  const move = bestMove(board);
+  if (move === -1) { endGame('draw'); return; }
+  board[move] = 'o';
+  renderBoard();
+  const res = checkWinner(board);
+  if (res) { endGame('lose'); return; }
+  if (board.every(c => c)) { endGame('draw'); return; }
+  setStatus('\u00a0', '');
+}
+
+function endGame(outcome) {
+  gameOver = true;
+  if (outcome === 'win') {
+    wins++;
+    document.getElementById('winsEl').textContent = wins;
+    setStatus('You win!', 'win');
+    document.getElementById('scoreInput').value = wins;
+    document.getElementById('scoreSubmit').style.display = 'block';
+  } else if (outcome === 'lose') {
+    losses++;
+    document.getElementById('lossesEl').textContent = losses;
+    setStatus('AI wins!', 'lose');
   } else {
-    outcome = NAMES[aiChoice] + ' beats ' + NAMES[chosen] + ' \u2014 AI WINS!'; outcomeClass = 'lose';
-    aiWins++; streak = 0;
-    roundHistory.push('lose');
-    aiWinsEl.textContent = aiWins;
+    draws++;
+    document.getElementById('drawsEl').textContent = draws;
+    setStatus("It's a draw!", 'draw');
   }
+}
 
-  resultEl.className = outcomeClass;
-  resultEl.textContent = outcome;
-  updateRounds();
-
-  const total = playerWins + aiWins + draws;
-  if (playerWins >= 3 || aiWins >= 3 || total >= MAX_ROUNDS) {
-    gameOver = true;
-    if (playerWins > aiWins) { finalEl.className = 'win'; finalEl.textContent = 'YOU WIN THE MATCH! Best streak: ' + bestStreak; document.getElementById('scoreInput').value = bestStreak; document.getElementById('scoreSubmit').style.display = 'block'; }
-    else if (aiWins > playerWins) { finalEl.className = 'lose'; finalEl.textContent = 'AI WINS THE MATCH!'; }
-    else { finalEl.className = 'draw'; finalEl.textContent = "IT'S A TIE!"; }
-    playBtn.textContent = 'New Game';
-    playBtn.disabled = false;
-    playBtn.onclick = resetGame;
+function minimax(b, isMax, alpha, beta) {
+  const res = checkWinner(b);
+  if (res) return res.winner === 'o' ? 10 : -10;
+  if (b.every(c => c)) return 0;
+  if (isMax) {
+    let best = -Infinity;
+    for (let i = 0; i < 9; i++) {
+      if (!b[i]) {
+        b[i] = 'o';
+        best = Math.max(best, minimax(b, false, alpha, beta));
+        b[i] = null;
+        alpha = Math.max(alpha, best);
+        if (beta <= alpha) break;
+      }
+    }
+    return best;
   } else {
-    chosen = null;
-    document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
-    playBtn.disabled = true;
-    playBtn.textContent = 'Choose a move!';
+    let best = Infinity;
+    for (let i = 0; i < 9; i++) {
+      if (!b[i]) {
+        b[i] = 'x';
+        best = Math.min(best, minimax(b, true, alpha, beta));
+        b[i] = null;
+        beta = Math.min(beta, best);
+        if (beta <= alpha) break;
+      }
+    }
+    return best;
   }
 }
 
-playBtn.addEventListener('click', onPlay);
-
-function resetGame() {
-  playerWins = 0; aiWins = 0; draws = 0; roundHistory = []; chosen = null; gameOver = false; streak = 0; bestStreak = 0;
-  playerWinsEl.textContent = '0'; aiWinsEl.textContent = '0'; drawsEl.textContent = '0';
-  playerEmojiEl.textContent = '?'; aiEmojiEl.textContent = '?';
-  resultEl.textContent = '\u00a0'; resultEl.className = '';
-  finalEl.textContent = '\u00a0'; finalEl.className = '';
-  document.getElementById('scoreSubmit').style.display = 'none';
-  document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
-  playBtn.disabled = true;
-  playBtn.textContent = 'Choose a move!';
-  playBtn.onclick = onPlay;
-  updateRounds();
+function bestMove(b) {
+  let best = -Infinity, move = -1;
+  for (let i = 0; i < 9; i++) {
+    if (!b[i]) {
+      b[i] = 'o';
+      const score = minimax(b, false, -Infinity, Infinity);
+      b[i] = null;
+      if (score > best) { best = score; move = i; }
+    }
+  }
+  return move;
 }
 
-updateRounds();
+document.getElementById('new-btn').addEventListener('click', newGame);
+init();
 </script>
 </body>
 </html>

+ 15 - 24
src/games/tiktaktoe/thumbnail.svg

@@ -1,27 +1,18 @@
 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
   <rect width="400" height="220" fill="#000"/>
-  <rect x="120" y="20" width="160" height="180" rx="8" fill="#111"/>
-  <line x1="174" y1="28" x2="174" y2="192" stroke="#444" stroke-width="3"/>
-  <line x1="226" y1="28" x2="226" y2="192" stroke="#444" stroke-width="3"/>
-  <line x1="128" y1="80" x2="272" y2="80" stroke="#444" stroke-width="3"/>
-  <line x1="128" y1="132" x2="272" y2="132" stroke="#444" stroke-width="3"/>
-  <g stroke="#FFA500" stroke-width="4" fill="none" stroke-linecap="round">
-    <line x1="135" y1="35" x2="165" y2="68"/>
-    <line x1="165" y1="35" x2="135" y2="68"/>
-  </g>
-  <circle cx="200" cy="52" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
-  <g stroke="#FFA500" stroke-width="4" fill="none" stroke-linecap="round">
-    <line x1="238" y1="35" x2="268" y2="68"/>
-    <line x1="268" y1="35" x2="238" y2="68"/>
-  </g>
-  <circle cx="151" cy="106" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
-  <g stroke="#FFA500" stroke-width="4" fill="none" stroke-linecap="round">
-    <line x1="187" y1="88" x2="217" y2="122"/>
-    <line x1="217" y1="88" x2="187" y2="122"/>
-  </g>
-  <circle cx="253" cy="106" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
-  <circle cx="151" cy="158" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
-  <circle cx="200" cy="158" r="16" stroke="#4CAF50" stroke-width="4" fill="none"/>
-  <line x1="120" y1="158" x2="280" y2="158" stroke="#FFA500" stroke-width="5" opacity="0.6"/>
-  <text x="200" y="215" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">Rock · Paper · Scissors</text>
+  <line x1="147" y1="40" x2="147" y2="180" stroke="#444" stroke-width="3"/>
+  <line x1="253" y1="40" x2="253" y2="180" stroke="#444" stroke-width="3"/>
+  <line x1="80" y1="93" x2="320" y2="93" stroke="#444" stroke-width="3"/>
+  <line x1="80" y1="127" x2="320" y2="127" stroke="#444" stroke-width="3"/>
+  <text x="113" y="88" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
+  <text x="200" y="88" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
+  <text x="287" y="88" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
+  <text x="113" y="122" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
+  <text x="200" y="122" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
+  <text x="287" y="122" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
+  <text x="113" y="170" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
+  <text x="200" y="170" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
+  <text x="287" y="170" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
+  <line x1="80" y1="40" x2="320" y2="180" stroke="#FFA500" stroke-width="3" opacity="0.5"/>
+  <text x="200" y="210" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">Tic · Tac · Toe</text>
 </svg>

+ 4 - 1
src/models/activity_model.js

@@ -228,6 +228,7 @@ module.exports = ({ cooler }) => {
             const baseId = tip.id;
             const baseTitle = (tip.content && tip.content.title) || '';
             const isAnonymous = tip.content && typeof tip.content.isAnonymous === 'boolean' ? tip.content.isAnonymous : false;
+            if (isAnonymous) continue;
 
             const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
             const toSet = (xs) => new Set(uniq(xs));
@@ -494,13 +495,15 @@ module.exports = ({ cooler }) => {
       deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
 
       const tribeInternalTypes = new Set(['tribeLeave', 'tribeFeedPost', 'tribeFeedRefeed', 'tribe-content']);
-      const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent']);
+      const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'feed-action']);
       const isAllowedTribeActivity = (a) => !tribeInternalTypes.has(a.type);
       const isVisible = (a) => {
         if (hiddenTypes.has(a.type)) return false;
         if (a.type === 'pad' && (a.content || {}).status !== 'OPEN') return false;
         if (a.type === 'chat' && (a.content || {}).status !== 'OPEN') return false;
         if (a.type === 'calendar' && (a.content || {}).status !== 'OPEN') return false;
+        if (a.type === 'event' && String((a.content || {}).isPublic || '').toLowerCase() === 'private' && (a.content || {}).organizer !== userId && !(Array.isArray((a.content || {}).attendees) && (a.content || {}).attendees.includes(userId))) return false;
+        if (a.type === 'task' && String((a.content || {}).isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray((a.content || {}).assignees) && (a.content || {}).assignees.includes(userId))) return false;
         return true;
       };
 

+ 4 - 1
src/models/chats_model.js

@@ -447,7 +447,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const chat = await this.getChatById(chatId)
       if (!chat) throw new Error("Chat not found")
       if (chat.status === "CLOSED") throw new Error("Chat is closed")
-      if (!chat.members.includes(userId)) throw new Error("Not a participant")
+      if (!chat.members.includes(userId)) {
+        if (chat.status === "OPEN") await this.joinChat(chatId)
+        else throw new Error("Not a participant")
+      }
 
       const messages = await readAll(ssbClient)
       const oneHourAgo = Date.now() - 60 * 60 * 1000

+ 183 - 32
src/models/courts_model.js

@@ -9,7 +9,13 @@ const CASE_DECISION_DAYS = 21;
 const POPULAR_DAYS = 14;
 const FEED_ID_RE = /^@.+\.ed25519$/;
 
-module.exports = ({ cooler, services = {} }) => {
+const CASE_FIELDS = ['title', 'accuser', 'respondentId', 'mediatorsAccuser', 'mediatorsRespondent'];
+const EVIDENCE_FIELDS = ['text', 'link', 'imageUrl'];
+const ANSWER_FIELDS = ['stance', 'text'];
+const VERDICT_FIELDS = ['result', 'orders'];
+const SETTLEMENT_FIELDS = ['terms'];
+
+module.exports = ({ cooler, services = {}, tribeCrypto }) => {
   let ssb;
   let userId;
 
@@ -24,6 +30,45 @@ module.exports = ({ cooler, services = {} }) => {
   const nowISO = () => new Date().toISOString();
   const ensureArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
 
+  const encryptFields = (content, keyHex, fields) => {
+    const payload = {};
+    for (const f of fields) {
+      if (content[f] !== undefined) payload[f] = content[f];
+    }
+    const enc = tribeCrypto.encryptWithKey(JSON.stringify(payload), keyHex);
+    const result = { ...content };
+    for (const f of fields) delete result[f];
+    result.encryptedPayload = enc;
+    return result;
+  };
+
+  const decryptFields = (content, keyHex) => {
+    if (!content || !content.encryptedPayload) return content;
+    try {
+      const plain = tribeCrypto.decryptWithKey(content.encryptedPayload, keyHex);
+      const payload = JSON.parse(plain);
+      const result = { ...content };
+      delete result.encryptedPayload;
+      return { ...result, ...payload };
+    } catch (e) {
+      return { ...content, encrypted: true };
+    }
+  };
+
+  const getCaseKey = (obj) => {
+    if (!tribeCrypto || !obj) return null;
+    return tribeCrypto.getKey(obj.rootCaseId || obj.id);
+  };
+
+  const distributeKey = async (caseKey, caseRootId, recipientId) => {
+    if (!tribeCrypto || !recipientId) return;
+    const ssbClient = await openSsb();
+    const ssbKeys = require('../server/node_modules/ssb-keys');
+    const boxed = tribeCrypto.boxKeyForMember(caseKey, recipientId, ssbKeys);
+    const content = { type: 'courts-key', caseRootId, for: recipientId, memberKey: boxed };
+    await new Promise((res, rej) => ssbClient.publish(content, (e) => e ? rej(e) : res()));
+  };
+
   async function readLog() {
     const ssbClient = await openSsb();
     return new Promise((resolve, reject) => {
@@ -119,14 +164,36 @@ module.exports = ({ cooler, services = {} }) => {
       mediatorsRespondent: [],
       createdAt: openedAt
     };
-    return await new Promise((resolve, reject) =>
+
+    if (tribeCrypto) {
+      const initialMsg = await new Promise((res, rej) =>
+        ssbClient.publish(content, (err, msg) => (err ? rej(err) : res(msg)))
+      );
+      const caseRootId = initialMsg.key;
+      const caseKey = tribeCrypto.generateTribeKey();
+      tribeCrypto.setKey(caseRootId, caseKey, 1);
+      const encrypted = encryptFields({ ...content, rootCaseId: caseRootId }, caseKey, CASE_FIELDS);
+      const update = { ...encrypted, replaces: caseRootId, updatedAt: openedAt };
+      const finalMsg = await new Promise((res, rej) =>
+        ssbClient.publish(update, (err, msg) => (err ? rej(err) : res(msg)))
+      );
+      await distributeKey(caseKey, caseRootId, resp.id);
+      return finalMsg;
+    }
+
+    return new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
   }
 
   async function listCases(filter = 'open') {
     const all = await listByType('courtsCase');
-    const sorted = all.sort((a, b) => {
+    const decrypted = all.map(c => {
+      if (!tribeCrypto) return c;
+      const key = getCaseKey(c);
+      return key ? decryptFields(c, key) : c;
+    });
+    const sorted = decrypted.sort((a, b) => {
       const ta = new Date(a.openedAt || a.createdAt || 0).getTime();
       const tb = new Date(b.openedAt || b.createdAt || 0).getTime();
       return tb - ta;
@@ -150,7 +217,8 @@ module.exports = ({ cooler, services = {} }) => {
     const all = await listByType('courtsCase');
     const id = String(uid || userId || '');
     const rows = [];
-    for (const c of all) {
+    for (const raw of all) {
+      const c = tribeCrypto ? (getCaseKey(raw) ? decryptFields(raw, getCaseKey(raw)) : raw) : raw;
       const isAccuser = String(c.accuser || '') === id;
       const isRespondent = String(c.respondentId || '') === id;
       const ma = ensureArray(c.mediatorsAccuser || []);
@@ -190,19 +258,24 @@ module.exports = ({ cooler, services = {} }) => {
     const id = String(caseId || '').trim();
     if (!id) return null;
     const all = await listByType('courtsCase');
-    return all.find((c) => c.id === id) || null;
+    const found = all.find((c) => c.id === id) || null;
+    if (!found || !tribeCrypto) return found;
+    const caseKey = getCaseKey(found);
+    if (!caseKey) return found;
+    return decryptFields(found, caseKey);
   }
 
   async function upsertCase(obj) {
     const ssbClient = await openSsb();
     const { id, ...rest } = obj;
-    const updated = {
-      ...rest,
-      type: 'courtsCase',
-      replaces: id,
-      updatedAt: nowISO()
-    };
-    return await new Promise((resolve, reject) =>
+    let updated = { ...rest, type: 'courtsCase', replaces: id, updatedAt: nowISO() };
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(updated);
+      if (caseKey) {
+        updated = encryptFields(updated, caseKey, CASE_FIELDS);
+      }
+    }
+    return new Promise((resolve, reject) =>
       ssbClient.publish(updated, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
   }
@@ -237,6 +310,17 @@ module.exports = ({ cooler, services = {} }) => {
     const clean = list.filter((id) => id !== c.accuser && id !== c.respondentId);
     if (side === 'accuser') c.mediatorsAccuser = clean;
     else c.mediatorsRespondent = clean;
+
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(c);
+      if (caseKey) {
+        const caseRootId = c.rootCaseId || c.id;
+        for (const mediatorId of clean) {
+          await distributeKey(caseKey, caseRootId, mediatorId);
+        }
+      }
+    }
+
     await upsertCase(c);
     return c;
   }
@@ -255,6 +339,14 @@ module.exports = ({ cooler, services = {} }) => {
       throw new Error('Judge cannot be a party of the case.');
     }
     c.judgeId = id;
+
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(c);
+      if (caseKey) {
+        await distributeKey(caseKey, c.rootCaseId || c.id, id);
+      }
+    }
+
     await upsertCase(c);
     return c;
   }
@@ -273,9 +365,9 @@ module.exports = ({ cooler, services = {} }) => {
     }
     if (!t && !l && !imageUrl) throw new Error('Text, link or image is required.');
     const ssbClient = await openSsb();
-    const content = {
+    let content = {
       type: 'courtsEvidence',
-      caseId: c.id,
+      caseId: c.rootCaseId || c.id,
       author: userId,
       role,
       text: t,
@@ -283,7 +375,11 @@ module.exports = ({ cooler, services = {} }) => {
       imageUrl,
       createdAt: nowISO()
     };
-    return await new Promise((resolve, reject) =>
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(c);
+      if (caseKey) content = encryptFields(content, caseKey, EVIDENCE_FIELDS);
+    }
+    return new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
   }
@@ -298,14 +394,18 @@ module.exports = ({ cooler, services = {} }) => {
     const t = String(text || '').trim();
     if (!t) throw new Error('Response text is required.');
     const ssbClient = await openSsb();
-    const content = {
+    let content = {
       type: 'courtsAnswer',
-      caseId: c.id,
+      caseId: c.rootCaseId || c.id,
       respondent: userId,
       stance: s,
       text: t,
       createdAt: nowISO()
     };
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(c);
+      if (caseKey) content = encryptFields(content, caseKey, ANSWER_FIELDS);
+    }
     await new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
@@ -328,14 +428,18 @@ module.exports = ({ cooler, services = {} }) => {
     if (!r) throw new Error('Result is required.');
     const o = String(orders || '').trim();
     const ssbClient = await openSsb();
-    const content = {
+    let content = {
       type: 'courtsVerdict',
-      caseId: c.id,
+      caseId: c.rootCaseId || c.id,
       judgeId: userId,
       result: r,
       orders: o,
       createdAt: nowISO()
     };
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(c);
+      if (caseKey) content = encryptFields(content, caseKey, VERDICT_FIELDS);
+    }
     await new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
@@ -354,14 +458,18 @@ module.exports = ({ cooler, services = {} }) => {
     const t = String(terms || '').trim();
     if (!t) throw new Error('Terms are required.');
     const ssbClient = await openSsb();
-    const content = {
+    let content = {
       type: 'courtsSettlementProposal',
-      caseId: c.id,
+      caseId: c.rootCaseId || c.id,
       proposer: userId,
       terms: t,
       createdAt: nowISO()
     };
-    return await new Promise((resolve, reject) =>
+    if (tribeCrypto) {
+      const caseKey = getCaseKey(c);
+      if (caseKey) content = encryptFields(content, caseKey, SETTLEMENT_FIELDS);
+    }
+    return new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
   }
@@ -374,7 +482,7 @@ module.exports = ({ cooler, services = {} }) => {
     const ssbClient = await openSsb();
     const content = {
       type: 'courtsSettlementAccepted',
-      caseId: c.id,
+      caseId: c.rootCaseId || c.id,
       by: userId,
       createdAt: nowISO()
     };
@@ -462,7 +570,7 @@ module.exports = ({ cooler, services = {} }) => {
       judgeId: id,
       createdAt: nowISO()
     };
-    return await new Promise((resolve, reject) =>
+    return new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
   }
@@ -490,7 +598,7 @@ module.exports = ({ cooler, services = {} }) => {
       voter: userId,
       createdAt: nowISO()
     };
-    return await new Promise((resolve, reject) =>
+    return new Promise((resolve, reject) =>
       ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
     );
   }
@@ -550,26 +658,45 @@ module.exports = ({ cooler, services = {} }) => {
       myPublicPreference = base.publicPrefRespondent;
     }
     const publicDetails = base.publicPrefAccuser === true && base.publicPrefRespondent === true;
+
+    const caseKey = getCaseKey(base);
+    const caseRootId = base.rootCaseId || base.id;
+
+    const matchCase = (e) => {
+      const eCaseId = String(e.caseId || '');
+      return eCaseId === id || eCaseId === caseRootId;
+    };
+
+    const tryDecrypt = (item) => {
+      if (!caseKey) return item;
+      return decryptFields(item, caseKey);
+    };
+
     const evidencesAll = await listByType('courtsEvidence');
     const answersAll = await listByType('courtsAnswer');
     const settlementsAll = await listByType('courtsSettlementProposal');
     const verdictsAll = await listByType('courtsVerdict');
     const acceptedAll = await listByType('courtsSettlementAccepted');
+
     const evidences = evidencesAll
-      .filter((e) => String(e.caseId || '') === id)
+      .filter(matchCase)
+      .map(tryDecrypt)
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
     const answers = answersAll
-      .filter((a) => String(a.caseId || '') === id)
+      .filter(matchCase)
+      .map(tryDecrypt)
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
     const settlements = settlementsAll
-      .filter((s) => String(s.caseId || '') === id)
+      .filter(matchCase)
+      .map(tryDecrypt)
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
     const verdicts = verdictsAll
-      .filter((v) => String(v.caseId || '') === id)
+      .filter(matchCase)
+      .map(tryDecrypt)
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
     const verdict = verdicts.length ? verdicts[verdicts.length - 1] : null;
     const acceptedSettlements = acceptedAll
-      .filter((s) => String(s.caseId || '') === id)
+      .filter(matchCase)
       .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
     const decidedAt =
       base.verdictAt ||
@@ -601,6 +728,30 @@ module.exports = ({ cooler, services = {} }) => {
     };
   }
 
+  async function processIncomingCourtsKeys() {
+    if (!tribeCrypto) return;
+    const ssbKeys = require('../server/node_modules/ssb-keys');
+    const ssbConfig = require('../server/ssb_config');
+    const ssbClient = await openSsb();
+    const msgs = await new Promise((res, rej) => {
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((err, arr) => (err ? rej(err) : res(arr)))
+      );
+    });
+    for (const m of msgs) {
+      const c = m.value?.content;
+      if (!c || c.type !== 'courts-key') continue;
+      if (c.for !== ssbClient.id) continue;
+      if (!c.memberKey || !c.caseRootId) continue;
+      if (tribeCrypto.getKey(c.caseRootId)) continue;
+      try {
+        const key = tribeCrypto.unboxKeyFromMember(c.memberKey, ssbConfig.keys, ssbKeys);
+        if (key) tribeCrypto.setKey(c.caseRootId, key, 1);
+      } catch (e) {}
+    }
+  }
+
   return {
     getCurrentUserId,
     openCase,
@@ -619,7 +770,7 @@ module.exports = ({ cooler, services = {} }) => {
     nominateJudge,
     voteNomination,
     listNominations,
-    getCaseDetails
+    getCaseDetails,
+    processIncomingCourtsKeys
   };
 };
-

+ 1 - 2
src/models/feed_model.js

@@ -322,7 +322,6 @@ module.exports = ({ cooler }) => {
     if (filter === "TOP") {
       feeds.sort(
         (a, b) =>
-          totalVotes(b) - totalVotes(a) ||
           (b.value?.content?.refeeds || 0) - (a.value?.content?.refeeds || 0) ||
           getTs(b) - getTs(a)
       );
@@ -349,7 +348,7 @@ module.exports = ({ cooler }) => {
     let commentCount = 0;
     for (const a of actions) {
       const ac = a?.value?.content || {};
-      if (ac.type === "feed-action" && ac.action === "opinion" && ac.category) {
+      if (ac.type === "feed-action" && ac.action === "vote" && ac.category) {
         opinions[ac.category] = (opinions[ac.category] || 0) + 1;
         if (ac.author || a?.value?.author) opinionsInhabitants.push(ac.author || a.value.author);
       }

+ 1 - 1
src/models/games_model.js

@@ -3,7 +3,7 @@ const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 5000;
 
 const VALID_GAMES = new Set([
-  'cocoland', 'ecoinflow', 'spaceinvaders', 'arkanoid', 'pingpong',
+  'cocoland', 'ecoinflow', 'neoninfiltrator', 'spaceinvaders', 'arkanoid', 'pingpong',
   'asteroids', 'tiktaktoe', 'flipflop',
   '8ball', 'artillery', 'labyrinth', 'cocoman', 'tetris'
 ]);

+ 11 - 3
src/models/jobs_model.js

@@ -33,7 +33,7 @@ const matchSearch = (job, q) => {
   return hay.includes(qq)
 }
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto }) => {
   let ssb
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
 
@@ -114,6 +114,7 @@ module.exports = ({ cooler }) => {
   }
 
   const buildJobObject = (node, rootId, subscribers) => {
+    const visibleSubs = (tribeCrypto && tribeCrypto.getKey(rootId)) || ssb?.id === (node.c?.author || node.author) ? subscribers : [];
     const c = node.c || {}
     let blobId = c.image || null
     if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
@@ -141,7 +142,7 @@ module.exports = ({ cooler }) => {
       updatedAt: c.updatedAt || null,
       status: c.status || "OPEN",
       tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
-      subscribers: Array.isArray(subscribers) ? subscribers : [],
+      subscribers: Array.isArray(visibleSubs) ? visibleSubs : [],
       mapUrl: c.mapUrl || ""
     }
   }
@@ -196,7 +197,14 @@ module.exports = ({ cooler }) => {
         mapUrl: String(jobData.mapUrl || "").trim()
       }
 
-      return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
+      return new Promise((res, rej) => ssbClient.publish(content, (e, m) => {
+        if (e) return rej(e)
+        if (m && m.key && tribeCrypto) {
+          const key = tribeCrypto.generateTribeKey()
+          tribeCrypto.setKey(m.key, key, 1)
+        }
+        res(m)
+      }))
     },
 
     async resolveCurrentId(jobId) {

+ 11 - 4
src/models/market_model.js

@@ -53,7 +53,7 @@ const hasBidder = (poll, userId) => {
   return false
 }
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto }) => {
   let ssb
   const openSsb = async () => {
     if (!ssb) ssb = await cooler.open()
@@ -138,7 +138,14 @@ module.exports = ({ cooler }) => {
       }
 
       return new Promise((resolve, reject) => {
-        ssbClient.publish(itemContent, (err, res) => (err ? reject(err) : resolve(res)))
+        ssbClient.publish(itemContent, (err, res) => {
+          if (err) return reject(err)
+          if (res && res.key && tribeCrypto) {
+            const key = tribeCrypto.generateTribeKey()
+            tribeCrypto.setKey(res.key, key, 1)
+          }
+          resolve(res)
+        })
       })
     },
 
@@ -326,7 +333,7 @@ module.exports = ({ cooler }) => {
           includesShipping: !!c.includesShipping,
           stock: Number(c.stock) || 0,
           deadline: c.deadline || null,
-          auctions_poll: Array.isArray(c.auctions_poll) ? c.auctions_poll : [],
+          auctions_poll: (tribeCrypto && tribeCrypto.getKey(rootId)) ? (Array.isArray(c.auctions_poll) ? c.auctions_poll : []) : [],
           mapUrl: c.mapUrl || "",
           shopProductId: c.shopProductId || "",
           shopId: c.shopId || "",
@@ -464,7 +471,7 @@ module.exports = ({ cooler }) => {
         includesShipping: !!c.includesShipping,
         stock: Number(c.stock) || 0,
         deadline: c.deadline,
-        auctions_poll: Array.isArray(c.auctions_poll) ? c.auctions_poll : [],
+        auctions_poll: (tribeCrypto && tribeCrypto.getKey(rootId)) ? (Array.isArray(c.auctions_poll) ? c.auctions_poll : []) : [],
         mapUrl: c.mapUrl || "",
         shopProductId: c.shopProductId || "",
         shopId: c.shopId || "",

+ 1 - 0
src/models/pm_model.js

@@ -65,6 +65,7 @@ module.exports = ({ cooler }) => {
       const author = decrypted?.value?.author;
       const originalRecps = Array.isArray(content?.to) ? content.to : [];
       if (!content || !author) throw new Error("Malformed message.");
+      if (author !== userId) throw new Error("Not the author.");
       if (content.type === 'tombstone') throw new Error("Message already deleted.");
       const tombstone = {
         type: 'tombstone',

+ 10 - 3
src/models/shops_model.js

@@ -12,7 +12,7 @@ const normalizeTags = (raw) => {
 }
 const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto }) => {
   let ssb
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
 
@@ -91,7 +91,7 @@ module.exports = ({ cooler }) => {
       updatedAt: c.updatedAt || null,
       opinions: c.opinions || {},
       opinions_inhabitants: safeArr(c.opinions_inhabitants),
-      buyers: safeArr(c.buyers)
+      buyers: (tribeCrypto && tribeCrypto.getKey(rootId)) || ssb?.id === (c.author || node.author) ? safeArr(c.buyers) : []
     }
   }
 
@@ -301,7 +301,14 @@ module.exports = ({ cooler }) => {
       }
 
       return new Promise((resolve, reject) => {
-        ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
+        ssbClient.publish(content, (err, msg) => {
+          if (err) return reject(err)
+          if (msg && msg.key && tribeCrypto) {
+            const key = tribeCrypto.generateTribeKey()
+            tribeCrypto.setKey(msg.key, key, 1)
+          }
+          resolve(msg)
+        })
       })
     },
 

+ 19 - 3
src/models/tags_model.js

@@ -2,7 +2,7 @@ const pull = require('../server/node_modules/pull-stream');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
-module.exports = ({ cooler, padsModel }) => {
+module.exports = ({ cooler, padsModel, tribesModel }) => {
   let ssb;
   const openSsb = async () => {
     if (!ssb) ssb = await cooler.open();
@@ -130,11 +130,25 @@ module.exports = ({ cooler, padsModel }) => {
 
       for (const oldId of replacesMap.keys()) latestByKey.delete(oldId);
 
+      const anonTribeIds = new Set();
+      if (tribesModel) {
+        const allTribes = await tribesModel.listAll().catch(() => []);
+        for (const tribe of allTribes) {
+          if (tribe.isAnonymous === true) anonTribeIds.add(tribe.id);
+        }
+      }
+
       let filtered = Array.from(latestByKey.values()).filter(msg => {
         const c = msg?.value?.content;
         if (!c || c.type === 'tombstone') return false;
         if (tombstoned.has(msg.key)) return false;
-        return Array.isArray(c.tags) && c.tags.filter(Boolean).length > 0;
+        if (!Array.isArray(c.tags) || !c.tags.filter(Boolean).length) return false;
+        if (c.tribeId && anonTribeIds.has(c.tribeId)) return false;
+        if (c.type === 'event' && c.isPublic === 'private') return false;
+        if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE') return false;
+        if ((c.type === 'chat' || c.type === 'pad') && c.status === 'INVITE-ONLY') return false;
+        if (c.type === 'shop' && c.visibility === 'CLOSED') return false;
+        return true;
       });
 
       filtered = dedupeKeepLatest(filtered);
@@ -153,9 +167,11 @@ module.exports = ({ cooler, padsModel }) => {
       }
 
       if (padsModel) {
-        const viewerId = '';
+        const ssbClient2 = await openSsb();
+        const viewerId = ssbClient2.id;
         const pads = await padsModel.listAll({ filter: 'all', viewerId }).catch(() => []);
         for (const pad of pads) {
+          if (pad.status === 'INVITE-ONLY' && pad.author !== viewerId && !(Array.isArray(pad.members) && pad.members.includes(viewerId))) continue;
           if (!Array.isArray(pad.tags)) continue;
           const uniquePadTags = new Set(pad.tags.map(tagKey).filter(Boolean));
           for (const k of uniquePadTags) {

+ 15 - 0
src/server/nodemon.json

@@ -0,0 +1,15 @@
+{
+  "watch": [
+    "../backend",
+    "../models",
+    "../views",
+    "../client"
+  ],
+  "exec": "node ../backend/backend.js",
+  "ext": "js,json",
+  "ignore": [
+    "../client/assets/"
+  ],
+  "signal": "SIGTERM",
+  "delay": 500
+}

文件差异内容过多而无法显示
+ 328 - 147
src/server/package-lock.json


+ 6 - 1
src/server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.7.0",
+  "version": "0.7.1",
   "description": "Oasis - Social Networking Utopia",
   "repository": {
     "type": "git",
@@ -16,6 +16,8 @@
     "start": "npm run start:ssb && sleep 10 && npm run start:backend",
     "start:backend": "node ../backend/backend.js",
     "start:ssb": "node SSB_server.js start &",
+    "dev": "npm run dev:backend",
+    "dev:backend": "nodemon",
     "postinstall": "node ../../scripts/patch-node-modules.js"
   },
   "dependencies": {
@@ -137,6 +139,9 @@
     "fsevents": "^2.3.2",
     "sharp": "^0.33.5"
   },
+  "devDependencies": {
+    "nodemon": "^3.1.0"
+  },
   "bugs": {
     "url": "https://github.com/epsylon/oasis/issues"
   },

+ 11 - 2
src/server/ssb_metadata.js

@@ -37,7 +37,7 @@ async function checkForUpdate() {
   await updater.getRemoteVersion();
 }
 
-async function printMetadata(mode, modeColor = colors.cyan) {
+async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, httpHost = 'localhost', offline = false, isPublic = false) {
   if (printed) return;
   printed = true;
 
@@ -46,17 +46,26 @@ async function printMetadata(mode, modeColor = colors.cyan) {
   const name = pkg.name;
   const logLevel = config.logging?.level || 'info';
   const publicKey = config.keys?.public || '';
+  const httpUrl = `http://${httpHost}:${httpPort}`;
+  const oscLink = `\x1b]8;;${httpUrl}\x07${httpUrl}\x1b]8;;\x07`;
+  const ssbPort = config.connections?.incoming?.net?.[0]?.port || config.port || 8008;
+  const localDiscovery = config.local === true;
+  const hops = config.conn?.hops ?? config.friends?.hops ?? 2;
 
   console.log("=========================");
   console.log(`Running mode: ${modeColor}${mode}${colors.reset}`);
   console.log("=========================");
   console.log(`- Package: ${colors.blue}${name} ${colors.yellow}[Version: ${version}]${colors.reset}`);
-  console.log("- Logging Level:", logLevel);
+  console.log(`- URL: ${colors.cyan}${oscLink}${colors.reset}`);
   console.log(`- Oasis ID: [ ${colors.orange}@${publicKey}${colors.reset} ]`);
+  console.log("- Logging Level:", logLevel);
   const ifaces = os.networkInterfaces();
   const isOnline = Object.values(ifaces).some(list =>
     list && list.some(i => !i.internal && i.family === 'IPv4')
   );
+  console.log(`- Protocol (port): ${ssbPort}`);
+  console.log(`- LAN broadcasting (UDP): ${localDiscovery ? 'enabled' : 'disabled'}`);
+  console.log(`- Replication (hops): ${hops}`);
   console.log(`- Mode: ${isOnline ? 'online' : 'offline'}`);
   console.log("");
   console.log("=========================");

+ 32 - 14
src/views/blockchain_view.js

@@ -215,7 +215,7 @@ const renderBlockDiagram = (blocks, qs) => {
   );
 };
 
-const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, viewMode = 'block') => {
+const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, viewMode = 'block', restricted = false) => {
   if (!block) {
     return template(
       i18n.blockchain,
@@ -232,9 +232,8 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
   const qs = toQueryString(filter, search);
   const isDatagram = viewMode === 'datagram';
 
-  const blockContent = isDatagram
-    ? renderBlockDiagram([block], qs)
-    : div(
+  const blockContent = restricted
+    ? div(
         div({ class: 'block-single' },
           div({ class: 'block-row block-row--meta' },
             span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID}:`),
@@ -245,17 +244,36 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
             span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ')),
             span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`),
             span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
-          ),
-          div({ class: 'block-row block-row--meta block-row--meta-spaced' },
-            a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)
           )
         ),
-        div({ class:'block-row block-row--content' },
-          div({ class:'block-content-preview' },
-            pre({ class:'json-content' }, JSON.stringify(block.content,null,2))
-          )
+        div({ class: 'block-row block-row--content' },
+          p({ class: 'access-denied-msg' }, i18n.blockAccessRestricted)
         )
-      );
+      )
+    : isDatagram
+      ? renderBlockDiagram([block], qs)
+      : div(
+          div({ class: 'block-single' },
+            div({ class: 'block-row block-row--meta' },
+              span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID}:`),
+              span({ class: 'blockchain-card-value' }, block.id)
+            ),
+            div({ class: 'block-row block-row--meta' },
+              span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`),
+              span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ')),
+              span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`),
+              span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
+            ),
+            div({ class: 'block-row block-row--meta block-row--meta-spaced' },
+              a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)
+            )
+          ),
+          div({ class:'block-row block-row--content' },
+            div({ class:'block-content-preview' },
+              pre({ class:'json-content' }, JSON.stringify(block.content,null,2))
+            )
+          )
+        );
 
   return template(
     i18n.blockchain,
@@ -363,8 +381,8 @@ const renderBlockchainView = (blocks, filter, userId, search = {}) => {
             .map(block=>
               div({ class:'block' },
                 div({ class:'block-buttons' },
-                  a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, '⦿'),
-                  a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram || 'Datagram' }, '⊞'),
+                  block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, '⦿'),
+                  block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram || 'Datagram' }, '⊞'),
                   !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
                     form({ method:'GET', action:getViewDetailsAction(block.type, block) },
                       button({ type:'submit', class:'filter-btn' }, i18n.visitContent)

+ 29 - 23
src/views/chats_view.js

@@ -148,6 +148,17 @@ const renderMessage = (msg, chatAuthor) => {
 }
 
 
+exports.renderChatInvitePage = (code) => {
+  const pageContent = div({ class: "invite-page" },
+    h2(i18n.tribeInviteCodeText, code),
+    form({ method: "GET", action: "/chats" },
+      input({ type: "hidden", name: "filter", value: "all" }),
+      button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
+    )
+  )
+  return template(i18n.chatInviteMode || "Invite", section(pageContent))
+}
+
 exports.chatsView = async (chats, filter, chatToEdit = null, params = {}) => {
   const q = safeText(params.q || "")
   const list = safeArr(chats)
@@ -204,6 +215,7 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
   const isAuthor = String(chat.author) === String(userId)
   const isMember = safeArr(chat.members).includes(userId)
   const fullShareUrl = `/chats/${encodeURIComponent(chat.key)}`
+  const isRestrictedInviteOnly = !isMember && !isAuthor && chat.status === "INVITE-ONLY"
 
   const statusLabel = chat.status === "CLOSED" ? i18n.chatStatusClosed :
     chat.status === "INVITE-ONLY" ? i18n.chatStatusInviteOnly : i18n.chatStatusOpen
@@ -223,7 +235,7 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
         td({ class: "tribe-info-label" }, i18n.chatCreatedAt),
         td({ class: "tribe-info-value", colspan: "3" }, moment(chat.createdAt).format("YYYY/MM/DD HH:mm"))
       ),
-      tr(
+      isRestrictedInviteOnly ? null : tr(
         td({ class: "tribe-info-value", colspan: "4" },
           a({ href: `/author/${encodeURIComponent(chat.author)}`, class: "user-link" }, chat.author)
         )
@@ -232,12 +244,12 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
         td({ class: "tribe-info-label" }, i18n.chatStatus),
         td({ class: "tribe-info-value", colspan: "3" }, statusLabel)
       ),
-      chat.category ? tr(
+      !isRestrictedInviteOnly && chat.category ? tr(
         td({ class: "tribe-info-label" }, i18n.chatCategoryLabel),
         td({ class: "tribe-info-value", colspan: "3" }, catLabel(chat.category))
       ) : null
     ),
-    div({ class: "tribe-side-actions" },
+    isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
       isAuthor
         ? form({ method: "POST", action: `/chats/generate-invite` },
             input({ type: "hidden", name: "chatId", value: chat.key }),
@@ -279,27 +291,19 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
           )
         : null
     ),
-    !isMember && chat.status !== "CLOSED"
+    !isMember && chat.status === "INVITE-ONLY"
       ? div({ class: "chat-join-section" },
-          chat.status === "INVITE-ONLY"
-            ? div({ class: "chat-invite-form" },
-                form({ method: "POST", action: "/chats/join-code" },
-                  input({ type: "hidden", name: "returnTo", value: returnTo }),
-                  label(i18n.chatInviteCodeLabel), br(),
-                  input({ type: "text", name: "code", required: true, placeholder: i18n.chatInviteCode }), br(), br(),
-                  button({ type: "submit", class: "filter-btn" }, i18n.chatJoinByInvite)
-                )
-              )
-            : null,
-          chat.status === "OPEN"
-            ? form({ method: "POST", action: `/chats/join/${encodeURIComponent(chat.key)}` },
-                input({ type: "hidden", name: "returnTo", value: returnTo }),
-                button({ type: "submit", class: "create-button" }, i18n.chatStartChatting)
-              )
-            : null
+          div({ class: "chat-invite-form" },
+            form({ method: "POST", action: "/chats/join-code" },
+              input({ type: "hidden", name: "returnTo", value: `/chats/${encodeURIComponent(chat.key)}` }),
+              label(i18n.chatInviteCodeLabel), br(),
+              input({ type: "text", name: "code", required: true, placeholder: i18n.chatInviteCode }), br(), br(),
+              button({ type: "submit", class: "filter-btn" }, i18n.chatJoinByInvite)
+            )
+          )
         )
       : null,
-    safeArr(chat.tags).length
+    !isRestrictedInviteOnly && safeArr(chat.tags).length
       ? div({ class: "tribe-side-tags" },
           safeArr(chat.tags).map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
         )
@@ -307,9 +311,11 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
   )
 
   const msgList = safeArr(messages)
-  const canWrite = isMember && chat.status !== "CLOSED"
+  const canWrite = (isMember || chat.status === "OPEN") && chat.status !== "CLOSED"
 
-  const chatMain = div({ class: "tribe-main chat-full-width" },
+  const chatMain = isRestrictedInviteOnly
+    ? div({ class: "tribe-main chat-full-width" }, p({ class: "access-denied-msg" }, i18n.chatAccessDenied))
+    : div({ class: "tribe-main chat-full-width" },
     canWrite
       ? div({ class: "chat-message-form" },
           form({ method: "POST", action: `/chats/${encodeURIComponent(chat.key)}/message`, enctype: "multipart/form-data" },

+ 1 - 4
src/views/cipher_view.js

@@ -67,10 +67,7 @@ const cipherView = async (encryptedText = "", decryptedText = "", iv = "", passw
     ? div({ class: "cipher-result visible encrypted-result" }, 
         label(i18n.cipherEncryptedMessageLabel),
         br(),br(),
-        div({ class: "cipher-text" }, encryptedText),
-        label(i18n.cipherPasswordUsedLabel),
-        br(),br(),
-        div({ class: "cipher-text" }, password) 
+        div({ class: "cipher-text" }, encryptedText)
       )
     : null;
 

+ 27 - 14
src/views/event_view.js

@@ -412,25 +412,38 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
   const attendees = safeArray(event.attendees);
   const urlHref = safeExternalHref(event.url);
 
+  const isPrivateNoAccess = normalizePrivacy(event.isPublic) === "private" &&
+    String(event.organizer) !== String(userId) &&
+    !attendees.includes(userId);
+
+  const filterBar = div(
+    { class: "filters" },
+    form(
+      { method: "GET", action: "/events" },
+      button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll),
+      button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine),
+      button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday),
+      button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek),
+      button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth),
+      button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear),
+      button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived),
+      button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton)
+    )
+  );
+
+  if (isPrivateNoAccess) {
+    return template(
+      event.title,
+      section(filterBar, p({ class: "access-denied-msg" }, i18n.contentAccessDenied))
+    );
+  }
+
   const topbar = renderEventTopbar(event, currentFilter, { single: true });
 
   return template(
     event.title,
     section(
-      div(
-        { class: "filters" },
-        form(
-          { method: "GET", action: "/events" },
-          button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll),
-          button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine),
-          button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday),
-          button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek),
-          button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth),
-          button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear),
-          button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived),
-          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton)
-        )
-      ),
+      filterBar,
       div(
         { class: "card card-section event" },
         topbar ? topbar : null,

+ 3 - 3
src/views/feed_view.js

@@ -170,7 +170,7 @@ const renderFeedCard = (feed) => {
                 h1(String(refeedsNum)),
                 form(
                     { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
-                    button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", disabled: !!alreadyRefeeded }, i18n.refeedButton)
+                    button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", ...(alreadyRefeeded ? { disabled: true } : {}) }, i18n.refeedButton)
                 ),
                 alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
             ),
@@ -344,7 +344,7 @@ exports.singleFeedView = (feed, comments = []) => {
             h1(String(refeedsNum)),
             form(
               { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
-              button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", disabled: !!alreadyRefeeded }, i18n.refeedButton)
+              button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", ...(alreadyRefeeded ? { disabled: true } : {}) }, i18n.refeedButton)
             ),
             alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
           ),
@@ -372,7 +372,7 @@ exports.singleFeedView = (feed, comments = []) => {
             form(
               { method: "POST", action: `/feed/opinions/${encodeURIComponent(feed.key)}/${cat}` },
               button(
-                { class: alreadyVoted ? "vote-btn disabled" : "vote-btn", type: "submit", disabled: !!alreadyVoted },
+                { class: alreadyVoted ? "vote-btn disabled" : "vote-btn", type: "submit", ...(alreadyVoted ? { disabled: true } : {}) },
                 `${i18n["vote" + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${content.opinions?.[cat] || 0}]`
               )
             )

+ 3 - 1
src/views/games_view.js

@@ -5,11 +5,13 @@ const moment = require("../server/node_modules/moment");
 const getGames = () => [
   { id: 'cocoland', title: () => i18n.gamesCocolandTitle, desc: () => i18n.gamesCocolandDesc },
   { id: 'ecoinflow', title: () => i18n.gamesTheFlowTitle, desc: () => i18n.gamesTheFlowDesc },
+  { id: 'neoninfiltrator', title: () => i18n.gamesNeonInfiltratorTitle, desc: () => i18n.gamesNeonInfiltratorDesc },
   { id: 'audiopendulum', title: () => i18n.gamesAudioPendulumTitle, desc: () => i18n.gamesAudioPendulumDesc },
   { id: 'spaceinvaders', title: () => i18n.gamesSpaceInvadersTitle, desc: () => i18n.gamesSpaceInvadersDesc },
   { id: 'arkanoid', title: () => i18n.gamesArkanoidTitle, desc: () => i18n.gamesArkanoidDesc },
   { id: 'pingpong', title: () => i18n.gamesPingPongTitle, desc: () => i18n.gamesPingPongDesc },
   { id: 'asteroids', title: () => i18n.gamesAsteroidsTitle, desc: () => i18n.gamesAsteroidsDesc },
+  { id: 'rockpaperscissors', title: () => i18n.gamesRockPaperScissorsTitle, desc: () => i18n.gamesRockPaperScissorsDesc },
   { id: 'tiktaktoe', title: () => i18n.gamesTikTakToeTitle, desc: () => i18n.gamesTikTakToeDesc },
   { id: 'flipflop', title: () => i18n.gamesFlipFlopTitle, desc: () => i18n.gamesFlipFlopDesc },
   { id: '8ball', title: () => i18n.games8BallTitle, desc: () => i18n.games8BallDesc },
@@ -53,7 +55,7 @@ const renderHallOfFame = (hall) => {
   );
 };
 
-const VALID_GAME_IDS = new Set(['cocoland','ecoinflow','audiopendulum','spaceinvaders','arkanoid','pingpong','asteroids','tiktaktoe','flipflop','8ball','artillery','labyrinth','cocoman','tetris']);
+const VALID_GAME_IDS = new Set(['cocoland','ecoinflow','neoninfiltrator','audiopendulum','spaceinvaders','arkanoid','pingpong','asteroids','rockpaperscissors','tiktaktoe','flipflop','8ball','artillery','labyrinth','cocoman','tetris']);
 
 exports.gameShellView = (name) => {
   if (!VALID_GAME_IDS.has(name)) {

+ 15 - 0
src/views/main_views.js

@@ -1006,6 +1006,21 @@ const template = (titlePrefix, ...elements) => {
 
 exports.template = template;
 
+exports.tribeAccessDeniedView = (tribe) => {
+  const tribeName = tribe && !tribe.isAnonymous ? tribe.title : "";
+  return template(
+    i18n.tribeContentAccessDenied,
+    div({ class: "div-center" },
+      h2(i18n.tribeContentAccessDenied),
+      p(i18n.tribeContentAccessDeniedMsg),
+      tribeName ? p({ class: "tribe-access-name" }, tribeName) : null,
+      div({ class: "visit-btn-centered" },
+        a({ href: "/tribes", class: "filter-btn" }, i18n.tribeViewTribes)
+      )
+    )
+  );
+};
+
 const thread = (messages) => {
   let lookingForTarget = true;
   let shallowest = Infinity;

+ 42 - 40
src/views/pads_view.js

@@ -97,7 +97,7 @@ const renderPadCard = (pad, filter) => {
       ),
       table({ class: "tribe-info-table" },
         tr(td(i18n.padStatusLabel || "Status"), td(renderStatus(pad.status, pad.isClosed))),
-        tr(td(i18n.padDeadlineLabel || "Deadline"), td(pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
+        pad.deadline ? tr(td(i18n.padDeadlineLabel || "Deadline"), td(moment(pad.deadline).format("YYYY-MM-DD HH:mm"))) : null
       ),
       div({ class: "tribe-card-members" },
         span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
@@ -144,6 +144,17 @@ const renderCreateForm = (padToEdit, params) => {
   )
 }
 
+exports.renderPadInvitePage = (code) => {
+  const pageContent = div({ class: "invite-page" },
+    h2(i18n.tribeInviteCodeText, code),
+    form({ method: "GET", action: "/pads" },
+      input({ type: "hidden", name: "filter", value: "all" }),
+      button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
+    )
+  )
+  return template(i18n.padInviteMode || "Invite", section(pageContent))
+}
+
 exports.padsView = async (pads, filter, padToEdit, params) => {
   const q = String((params && params.q) || "").trim()
   const isForm = filter === "create" || filter === "edit"
@@ -194,10 +205,10 @@ exports.singlePadView = async (pad, entries, params) => {
   const isMember = pad.members.includes(userId)
   const padClosed = pad.isClosed
   const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
-
   const shareUrl = `/pads/${encodeURIComponent(pad.rootId)}`
+  const isRestrictedInviteOnly = !isMember && !isAuthor && pad.status === "INVITE-ONLY"
 
-  const tags = Array.isArray(pad.tags) && pad.tags.length > 0
+  const tags = !isRestrictedInviteOnly && Array.isArray(pad.tags) && pad.tags.length > 0
     ? div({ class: "tribe-side-tags" }, ...pad.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t}`)))
     : null
 
@@ -215,11 +226,11 @@ exports.singlePadView = async (pad, entries, params) => {
     ),
     table({ class: "tribe-info-table" },
       tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))),
-      tr(td({ class: "tribe-info-value", colspan: "4" }, a({ href: `/author/${encodeURIComponent(pad.author)}`, class: "user-link" }, pad.author))),
+      isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value", colspan: "4" }, a({ href: `/author/${encodeURIComponent(pad.author)}`, class: "user-link" }, pad.author))),
       tr(td({ class: "tribe-info-label" }, i18n.padStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(pad.status, padClosed))),
-      tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
+      isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
     ),
-    div({ class: "tribe-side-actions" },
+    isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
       isAuthor
         ? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
             button({ type: "submit", class: "tribe-action-btn" }, i18n.padGenerateCode || "Generate Code")
@@ -260,18 +271,12 @@ exports.singlePadView = async (pad, entries, params) => {
           )
         )
       : null,
-    (!isAuthor && (pad.status === "OPEN" || isMember) && !padClosed)
+    !isRestrictedInviteOnly && (!isAuthor && (pad.status === "OPEN" || isMember) && !padClosed)
       ? form({ method: "POST", action: `/pads/join/${encodeURIComponent(pad.rootId)}` },
           button({ type: "submit", class: "create-button" }, i18n.padStartEditing || "START EDITING!")
         )
       : null,
-    tags,
-    params.inviteCode
-      ? div({ class: "pad-invite-section" },
-          p(i18n.padInviteGenerated || "Invite Code Generated"),
-          input({ type: "text", readonly: true, value: params.inviteCode })
-        )
-      : null
+    tags
   )
 
   let canonicalEntries = entries
@@ -291,24 +296,6 @@ exports.singlePadView = async (pad, entries, params) => {
       )
     : p(i18n.padNoEntries || "No entries yet.")
 
-  const editorArea = isMember && !padClosed && !params.selectedVersion
-    ? div({ class: "pad-editor-area" },
-        coloredView,
-        form({ method: "POST", action: `/pads/entry/${encodeURIComponent(pad.rootId)}` },
-          textarea({ name: "text", rows: "12", class: "pad-editor-white", placeholder: i18n.padEditorPlaceholder || "Start writing..." }, currentText),
-          button({ type: "submit", class: "create-button" }, i18n.padSubmitEntry || "Submit")
-        )
-      )
-    : div({ class: "pad-editor-area" },
-        params.selectedVersion
-          ? div({ class: "pad-viewer-back" },
-              a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" },
-                "\u2190 " + (i18n.padBackToEditor || "Back to editor"))
-            )
-          : null,
-        coloredView
-      )
-
   const versionList = entries.length > 0
     ? div({ class: "pad-version-list" },
         h4(i18n.padVersionHistory || "Version History"),
@@ -325,14 +312,29 @@ exports.singlePadView = async (pad, entries, params) => {
       )
     : null
 
-  const padMain = div({ class: "tribe-main" },
-    div({ class: "pad-main-layout" },
-      div({ class: "pad-main-left" },
-        div({ class: "pad-editor-container" }, editorArea)
-      ),
-      versionList ? div({ class: "pad-main-right" }, versionList) : null
-    )
-  )
+  const editorArea = isMember && !padClosed && !params.selectedVersion
+    ? div({ class: "pad-editor-area" },
+        coloredView,
+        form({ method: "POST", action: `/pads/entry/${encodeURIComponent(pad.rootId)}` },
+          textarea({ name: "text", rows: "12", class: "pad-editor-white", placeholder: i18n.padEditorPlaceholder || "Start writing..." }, currentText),
+          button({ type: "submit", class: "create-button" }, i18n.padSubmitEntry || "Submit")
+        ),
+        versionList ? div({ class: "pad-version-section" }, versionList) : null
+      )
+    : div({ class: "pad-editor-area" },
+        params.selectedVersion
+          ? div({ class: "pad-viewer-back" },
+              a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" },
+                "\u2190 " + (i18n.padBackToEditor || "Back to editor"))
+            )
+          : null,
+        coloredView,
+        versionList ? div({ class: "pad-version-section" }, versionList) : null
+      )
+
+  const padMain = isRestrictedInviteOnly
+    ? div({ class: "tribe-main" }, p({ class: "access-denied-msg" }, i18n.padAccessDenied))
+    : div({ class: "tribe-main" }, editorArea)
 
   return template(
     pad.title || i18n.padsTitle || "Pad",

+ 16 - 2
src/views/search_view.js

@@ -4,6 +4,8 @@ const moment = require("../server/node_modules/moment");
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { renderUrl } = require('../backend/renderUrl');
 const { sanitizeHtml } = require('../backend/sanitizeHtml');
+const { config } = require("../server/SSB_server.js");
+const userId = config.keys.id;
 
 const decodeMaybe = (s) => {
   try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); }
@@ -477,19 +479,31 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
           content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.searchPriceLabel || 'PRICE') + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null,
           content.stock !== undefined ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock)) : null
         );
-      case 'chat':
+      case 'chat': {
+        const chatInviteOnly = content.status === 'INVITE-ONLY' && content.author !== userId && !(Array.isArray(content.members) && content.members.includes(userId));
+        if (chatInviteOnly) return div({ class: 'search-chat' },
+          content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatsTitle || 'Chat') + ':'), span({ class: 'card-value' }, content.title)) : null,
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'STATUS') + ':'), span({ class: 'card-value' }, i18n.chatStatusInviteOnly || 'INVITE-ONLY'))
+        );
         return div({ class: 'search-chat' },
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatsTitle || 'Chat') + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatDescription || 'Description') + ':'), span({ class: 'card-value' }, content.description)) : null,
           content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatCategoryLabel || 'Category') + ':'), span({ class: 'card-value' }, content.category)) : null,
           content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'STATUS') + ':'), span({ class: 'card-value' }, content.status)) : null
         );
-      case 'pad':
+      }
+      case 'pad': {
+        const padInviteOnly = content.status === 'INVITE-ONLY' && content.author !== userId && !(Array.isArray(content.members) && content.members.includes(userId));
+        if (padInviteOnly) return div({ class: 'search-pad' },
+          content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padTitle || 'Pad') + ':'), span({ class: 'card-value' }, content.title)) : null,
+          div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padStatusLabel || 'Status') + ':'), span({ class: 'card-value' }, i18n.padStatusInviteOnly || 'INVITE-ONLY'))
+        );
         return div({ class: 'search-pad' },
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padTitle || 'Pad') + ':'), span({ class: 'card-value' }, content.title)) : null,
           content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padStatusLabel || 'Status') + ':'), span({ class: 'card-value' }, content.status)) : null,
           content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.padDeadlineLabel || 'Deadline') + ':'), span({ class: 'card-value' }, content.deadline)) : null
         );
+      }
       case 'gameScore':
         return div({ class: 'search-game' },
           content.game ? div({ class: 'game-row' },

+ 30 - 17
src/views/task_view.js

@@ -387,28 +387,41 @@ exports.singleTaskView = async (task, filter, comments = []) => {
   const assignees = safeArray(task.assignees);
   const commentCount = typeof task.commentCount === "number" ? task.commentCount : 0;
 
+  const isPrivateNoAccess = String(task.isPublic || "").toUpperCase() === "PRIVATE" &&
+    String(task.author) !== String(userId) &&
+    !assignees.includes(userId);
+
+  const filterBar = div(
+    { class: "filters" },
+    form(
+      { method: "GET", action: "/tasks" },
+      button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll),
+      button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine),
+      button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned),
+      button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen),
+      button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress),
+      button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed),
+      button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow),
+      button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium),
+      button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh),
+      button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent),
+      button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton)
+    )
+  );
+
+  if (isPrivateNoAccess) {
+    return template(
+      task.title,
+      section(filterBar, p({ class: "access-denied-msg" }, i18n.contentAccessDenied))
+    );
+  }
+
   const topbar = renderTaskTopbar(task, currentFilter, { single: true });
 
   return template(
     task.title,
     section(
-      div(
-        { class: "filters" },
-        form(
-          { method: "GET", action: "/tasks" },
-          button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll),
-          button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine),
-          button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned),
-          button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen),
-          button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress),
-          button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed),
-          button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow),
-          button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium),
-          button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh),
-          button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent),
-          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton)
-        )
-      ),
+      filterBar,
       div(
         { class: "card card-section task" },
         topbar ? topbar : null,