@memizy/host-sdk 1.0.0-rc.6 → 1.0.0-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog of @memizy/host-sdk
2
2
 
3
+ ## 1.0.0-rc.7 (2026-10-16)
4
+
5
+ * `LocalSession` gives the authority a secret random seed (`InitPayload.seed`; `SessionConfig.seed` only for replays and tests).
6
+ * The default seed of the display order (`shuffleSeed`) is random, no longer the session id: a player knows the session id and could undo the shuffle (authors often write sets in the answer's order). Pass the same `shuffleSeed` to continue a saved game.
7
+ * `getAsset` gives a player's instance in multiplayer only media of its own (public) set: media of the back of a flashcard or of an explanation can no longer be fetched by key.
8
+ * `recordAnswer` is accepted only from the authority (SPEC 5): another instance cannot prove an answer, so a modified player's device cannot write "correct" into a teacher's statistics.
9
+ * Answers to `open-ended` items are kept, not graded: the `answer` event has `ungraded: true` and no record, the learning progress is unchanged. Every `answer` event carries `setId` (an item is identified by set + item ID).
10
+ * `prepareSetForPlugin` leaves out items whose `mustUnderstand` names properties this version does not support.
11
+ * `leitner` keeps the learner's `starred` and fields of a later OQSEP version (also in `stats`) and stores the answer as `lastAnswer.answer`; the `LearningAlgorithm` contract says so.
12
+ * `LocalSession` gives settings of a later protocol version their default and ignores values sent for them.
13
+ * Public items of a session come from the new fail-closed `publicItem` of `@memizy/protocol`.
14
+
3
15
  ## 1.0.0-rc.6 (2026-10-14)
4
16
 
5
17
  Enforces the final protocol 1.0 ([SPEC](https://github.com/memizy/memizy/blob/main/packages/protocol/SPEC.md)).
package/README.md CHANGED
@@ -61,6 +61,6 @@ const player = new RelayPlayer({ serverUrl: 'https://mp.memizy.com', pin, name:
61
61
 
62
62
  `uploadBundle` takes the `PreparedSet` and uploads only its `publicSet`: anyone with the PIN can download the bundle, so answers never leave the host. Use the same seed for the bundle and `shuffleSeed`, so every device shows the same order.
63
63
 
64
- **Continuing a saved game** (SPEC 3.3): create the session with the saved players (their old IDs), the same `sessionId`, `shuffleSeed` and storage, `restore: true` and a paused `clock`, then `host.attach(session, { seats: true })`. Devices see the earlier places in `state.seats` and take one with `player.claimSeat(id)` (or `claimSeat(null)` to join as a new player when the game allows late joins); the host can change it with `host.assignSeat(roomPlayerId, seatId)` (`host.seats` lists the places with their `roomPlayerId`). Keep `host.seatAssignments` and pass it as `taken` to attach again after a reload of the host page. Memizy Play (`apps/play`, `HostView` and `JoinView`) is the reference host.
64
+ **Continuing a saved game** (SPEC 3.3): create the session with the saved players (their old IDs), the same `sessionId`, `shuffleSeed` and storage, `restore: true` and a paused `clock`, then `host.attach(session, { seats: true })`. Devices see the earlier places in `state.seats` and take one with `player.claimSeat(id)` (or `claimSeat(null)` to join as a new player when the game allows late joins); the host can change it with `host.assignSeat(roomPlayerId, seatId)` (`host.seats` lists the places with their `roomPlayerId`). Keep `host.seatAssignments` and pass it as `taken` to attach again after a reload of the host page. Memizy Play ([memizy.com/play](https://memizy.com/play/)) uses exactly this flow.
65
65
 
66
66
  Iframes are sandboxed with `allow-scripts allow-forms allow-modals allow-pointer-lock allow-popups allow-popups-to-escape-sandbox` (`PLUGIN_SANDBOX`; links open in a new tab) and **never** `allow-same-origin` or `allow-top-navigation`.
package/dist/learning.cjs CHANGED
@@ -1 +1 @@
1
- var e=[0,1,3,7,21],t={id:`leitner`,apply(t,n,r){let i=t?.stats??{attempts:0,incorrect:0,streak:0},a={isCorrect:!n.isSkipped&&n.isCorrect,answeredAt:r.toISOString(),...n.confidence!==void 0&&{confidence:n.confidence},...n.timeSpentMs!==void 0&&{timeSpent:n.timeSpentMs},...n.hintsUsed!==void 0&&{hintsUsed:n.hintsUsed},...n.isSkipped&&{isSkipped:!0}};if(n.isSkipped)return{...t??{bucket:0},stats:i,lastAnswer:a};let o=n.isCorrect?Math.min(4,Math.max(1,t?.bucket??0)+1):1,s=new Date(r.getTime()+e[o]*24*60*60*1e3);return{...t,bucket:o,nextReviewAt:s.toISOString(),stats:{attempts:i.attempts+1,incorrect:i.incorrect+ +!n.isCorrect,streak:n.isCorrect?i.streak+1:0},lastAnswer:a}}};exports.LEITNER_INTERVAL_DAYS=e,exports.leitner=t;
1
+ var e=[0,1,3,7,21],t={id:`leitner`,apply(t,n,r){let i=t?.stats??{attempts:0,incorrect:0,streak:0},a={isCorrect:!n.isSkipped&&n.isCorrect,answeredAt:r.toISOString(),...n.confidence!==void 0&&{confidence:n.confidence},...n.timeSpentMs!==void 0&&{timeSpent:n.timeSpentMs},...n.hintsUsed!==void 0&&{hintsUsed:n.hintsUsed},...n.isSkipped&&{isSkipped:!0},...n.answer!==void 0&&{answer:n.answer}};if(n.isSkipped)return{...t??{bucket:0},stats:i,lastAnswer:a};let o=n.isCorrect?Math.min(4,Math.max(1,t?.bucket??0)+1):1,s=new Date(r.getTime()+e[o]*24*60*60*1e3);return{...t,bucket:o,nextReviewAt:s.toISOString(),stats:{...i,attempts:i.attempts+1,incorrect:i.incorrect+ +!n.isCorrect,streak:n.isCorrect?i.streak+1:0},lastAnswer:a}}};exports.LEITNER_INTERVAL_DAYS=e,exports.leitner=t;
package/dist/learning.js CHANGED
@@ -18,7 +18,8 @@ var e = [
18
18
  ...n.confidence !== void 0 && { confidence: n.confidence },
19
19
  ...n.timeSpentMs !== void 0 && { timeSpent: n.timeSpentMs },
20
20
  ...n.hintsUsed !== void 0 && { hintsUsed: n.hintsUsed },
21
- ...n.isSkipped && { isSkipped: !0 }
21
+ ...n.isSkipped && { isSkipped: !0 },
22
+ ...n.answer !== void 0 && { answer: n.answer }
22
23
  };
23
24
  if (n.isSkipped) return {
24
25
  ...t ?? { bucket: 0 },
@@ -31,6 +32,7 @@ var e = [
31
32
  bucket: o,
32
33
  nextReviewAt: s.toISOString(),
33
34
  stats: {
35
+ ...i,
34
36
  attempts: i.attempts + 1,
35
37
  incorrect: i.incorrect + +!n.isCorrect,
36
38
  streak: n.isCorrect ? i.streak + 1 : 0
package/dist/plugin.cjs CHANGED
@@ -1 +1 @@
1
- let e=require("@memizy/protocol"),t=require("@memizy/oqse");function n(t){let n=(0,e.readPluginManifestFromHtml)(t);return n.success?(0,e.isProtocolSupported)(n.runtime.protocol)?{success:!0,plugin:{html:t,manifest:n.manifest,runtime:n.runtime}}:{success:!1,errors:[`The plugin needs Memizy Plugin Protocol ${n.runtime.protocol}, which this host does not support.`]}:n}async function r(e,t=fetch){let r=await t(e);return r.ok?n(await r.text()):{success:!1,errors:[`Could not download the plugin (${r.status} ${r.statusText}).`]}}function i(n,r,i={}){let a=r.capabilities?.types??[],o=a.includes(`*`)?n.items:n.items.filter(e=>a.includes(e.type)),s=(0,e.prepareDisplaySet)({meta:n.meta,items:o},i.seed);return{set:s.set,publicSet:s.publicSet,compatibility:(0,t.checkCompatibility)(n,r),skippedItems:n.items.length-o.length}}function a(e){let n=new Date().toISOString();return{version:t.OQSE_VERSION,meta:{id:(0,t.generateUUID)(),title:e.title,language:e.language,createdAt:n,updatedAt:n},items:[]}}exports.createEmptySet=a,exports.loadPluginFromHtml=n,exports.loadPluginFromUrl=r,exports.prepareSetForPlugin=i;
1
+ let e=require("@memizy/protocol"),t=require("@memizy/oqse");function n(t){let n=(0,e.readPluginManifestFromHtml)(t);return n.success?(0,e.isProtocolSupported)(n.runtime.protocol)?{success:!0,plugin:{html:t,manifest:n.manifest,runtime:n.runtime}}:{success:!1,errors:[`The plugin needs Memizy Plugin Protocol ${n.runtime.protocol}, which this host does not support.`]}:n}async function r(e,t=fetch){let r=await t(e);return r.ok?n(await r.text()):{success:!1,errors:[`Could not download the plugin (${r.status} ${r.statusText}).`]}}function i(n,r,i={}){let a=r.capabilities?.types??[],o=(a.includes(`*`)?n.items:n.items.filter(e=>a.includes(e.type))).filter(e=>(0,t.missingMustUnderstand)(e).length===0),s=(0,e.prepareDisplaySet)({meta:n.meta,items:o},i.seed);return{set:s.set,publicSet:s.publicSet,compatibility:(0,t.checkCompatibility)(n,r),skippedItems:n.items.length-o.length}}function a(e){let n=new Date().toISOString();return{version:t.OQSE_VERSION,meta:{id:(0,t.generateUUID)(),title:e.title,language:e.language,createdAt:n,updatedAt:n},items:[]}}exports.createEmptySet=a,exports.loadPluginFromHtml=n,exports.loadPluginFromUrl=r,exports.prepareSetForPlugin=i;
package/dist/plugin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { isProtocolSupported as e, prepareDisplaySet as t, readPluginManifestFromHtml as n } from "@memizy/protocol";
2
- import { OQSE_VERSION as r, checkCompatibility as i, generateUUID as a } from "@memizy/oqse";
2
+ import { OQSE_VERSION as r, checkCompatibility as i, generateUUID as a, missingMustUnderstand as o } from "@memizy/oqse";
3
3
  //#region src/plugin.ts
4
- function o(t) {
4
+ function s(t) {
5
5
  let r = n(t);
6
6
  return r.success ? e(r.runtime.protocol) ? {
7
7
  success: !0,
@@ -15,26 +15,26 @@ function o(t) {
15
15
  errors: [`The plugin needs Memizy Plugin Protocol ${r.runtime.protocol}, which this host does not support.`]
16
16
  } : r;
17
17
  }
18
- async function s(e, t = fetch) {
18
+ async function c(e, t = fetch) {
19
19
  let n = await t(e);
20
- return n.ok ? o(await n.text()) : {
20
+ return n.ok ? s(await n.text()) : {
21
21
  success: !1,
22
22
  errors: [`Could not download the plugin (${n.status} ${n.statusText}).`]
23
23
  };
24
24
  }
25
- function c(e, n, r = {}) {
26
- let a = n.capabilities?.types ?? [], o = a.includes("*") ? e.items : e.items.filter((e) => a.includes(e.type)), s = t({
25
+ function l(e, n, r = {}) {
26
+ let a = n.capabilities?.types ?? [], s = (a.includes("*") ? e.items : e.items.filter((e) => a.includes(e.type))).filter((e) => o(e).length === 0), c = t({
27
27
  meta: e.meta,
28
- items: o
28
+ items: s
29
29
  }, r.seed);
30
30
  return {
31
- set: s.set,
32
- publicSet: s.publicSet,
31
+ set: c.set,
32
+ publicSet: c.publicSet,
33
33
  compatibility: i(e, n),
34
- skippedItems: e.items.length - o.length
34
+ skippedItems: e.items.length - s.length
35
35
  };
36
36
  }
37
- function l(e) {
37
+ function u(e) {
38
38
  let t = (/* @__PURE__ */ new Date()).toISOString();
39
39
  return {
40
40
  version: r,
@@ -49,4 +49,4 @@ function l(e) {
49
49
  };
50
50
  }
51
51
  //#endregion
52
- export { l as createEmptySet, o as loadPluginFromHtml, s as loadPluginFromUrl, c as prepareSetForPlugin };
52
+ export { u as createEmptySet, s as loadPluginFromHtml, c as loadPluginFromUrl, l as prepareSetForPlugin };
package/dist/session.cjs CHANGED
@@ -1 +1 @@
1
- require("./version.cjs");const e=require("./plugin.cjs"),t=require("./learning.cjs"),n=require("./storage.cjs");let r=require("@memizy/protocol"),i=require("@memizy/oqse"),a=require("@memizy/protocol/internal");function o(e){try{return structuredClone(e)}catch{return JSON.parse(JSON.stringify(e))}}var s=class{id;plugin;runtime;mode;hostAs;authority;prepared;roster;settingValues;settingsOk=!0;hostConfig;isStarted=!1;isEnded=!1;pauseState={pausedMs:0,pausedAt:null};options;storage;algorithm;instances=new Map;listeners=new Set;authorityConnected=!0;startPromise=null;resumeStartPending=!1;pendingSnapshot=void 0;finished=!1;hasResult=!1;finishedSnapshot=null;snapshotTimer;pendingData=new Map;get players(){return this.roster}get settings(){return this.settingValues}get settingsValid(){return this.settingsOk}get config(){return this.hostConfig}get started(){return this.isStarted}get ended(){return this.isEnded}constructor(i){if(this.options=i,this.id=i.sessionId??`session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`,this.plugin=i.plugin,this.runtime=i.plugin.runtime,this.mode=i.mode,this.storage=i.storage??new n.MemoryStorage,this.algorithm=i.algorithm??t.leitner,this.hostConfig={locale:i.locale??`en`,theme:i.theme??`light`},i.mode===`solo`){if(!this.runtime.solo)throw Error(`The plugin does not support solo mode.`);this.hostAs=null}else{if(!this.runtime.multiplayer)throw Error(`The plugin does not support multiplayer.`);let e=i.hostAs??this.runtime.multiplayer.hostAs[0];if(!this.runtime.multiplayer.hostAs.includes(e))throw Error(`The plugin does not support hostAs "${e}".`);this.hostAs=e}let a=i.players??(i.mode===`solo`?[{id:`player`,name:`Player`,isHost:!0}]:[]);if(i.mode===`solo`&&a.length!==1)throw Error(`A solo session has exactly one player.`);if(new Set(a.map(e=>e.id)).size!==a.length)throw Error(`Player IDs must be unique.`);if(a.some(e=>e.id===r.BOARD_ADDRESS||e.id===r.SETTINGS_ADDRESS||e.id===`server`))throw Error(`Player IDs "board", "settings" and "server" are reserved.`);if(this.roster=a.map(e=>({id:e.id,name:e.name,isHost:i.mode===`solo`?!0:e.isHost??!1,connected:!0})),i.mode===`solo`)this.authority=this.roster[0].id;else if(this.hostAs===`presenter`)this.authority=r.BOARD_ADDRESS;else{let e=this.roster.find(e=>e.isHost);if(!e)throw Error(`With hostAs "player", one player must have isHost: true.`);this.authority=e.id}let s=(0,r.resolveSettings)(this.runtime.settings,i.settings??{});if(s.errors.length>0)throw Error(`Invalid settings: ${s.errors.join(`; `)}`);this.settingValues=s.values;let c=i.shuffleSeed===null?void 0:i.shuffleSeed??this.id;this.prepared=e.prepareSetForPlugin(o(i.set),i.plugin.manifest,{seed:c}),i.restore&&(this.isStarted=!0,this.resumeStartPending=!0),i.clock&&(this.pauseState={pausedMs:i.clock.pausedMs??0,pausedAt:i.clock.pausedAt??null})}on(e){return this.listeners.add(e),()=>this.listeners.delete(e)}emit(e){for(let t of this.listeners)try{t(e)}catch(e){console.error(`[memizy host] session listener failed:`,e)}}viewOf(e){return e===r.SETTINGS_ADDRESS?`settings`:this.mode===`solo`?`solo`:e===r.BOARD_ADDRESS?`board`:`controller`}addresses(){return[...this.hostAs===`presenter`?[r.BOARD_ADDRESS]:[],...this.roster.map(e=>e.id)]}async connect(e,t,n={}){let i=this.viewOf(e);if(i===`settings`){if(!this.runtime.multiplayer||!this.runtime.settingsScreen)throw Error(`The plugin has no settings screen (settingsScreen).`);if(this.isStarted)throw Error(`The settings screen is only available before the game starts.`)}else if(!this.addresses().includes(e))throw Error(`Unknown address "${e}" in this session.`);if(this.isEnded)throw Error(`The session has ended.`);this.disposeInstance(e);let o={address:e,view:i,hostApi:void 0,endpoint:t,onDispose:n.onDispose,plugin:null,ready:!1,connected:!0,disposed:!1,resumed:!1,greeted:!1,limiter:new a.RateLimiter(r.LIMITS.messagesPerSecond,r.LIMITS.messageBurst),serviceLimiter:new a.RateLimiter(r.LIMITS.servicesPerMinute/60,10),answerLimiter:new a.RateLimiter(r.LIMITS.answersPerSecond,r.LIMITS.answerBurst),assetLimiter:new a.RateLimiter(r.LIMITS.assetsPerSecond,r.LIMITS.assetBurst),errorLimiter:new a.RateLimiter(r.LIMITS.errorReportsPerSecond,r.LIMITS.errorReportBurst)};o.hostApi=this.createHostApi(o),this.instances.set(e,o),e===this.authority&&this.isStarted&&this.setAuthorityConnected(!1);try{o.plugin=await t(o.hostApi)}catch(e){if(o.disposed)return o;throw e}return o}async reload(e){let t=this.instances.get(e);if(!t)throw Error(`No instance at "${e}".`);return e===this.authority&&await this.flushSnapshot(),this.connect(e,t.endpoint,{onDispose:t.onDispose})}disconnect(e){this.disposeInstance(e),e===this.authority&&this.isStarted&&this.setAuthorityConnected(!1)}disposeInstance(e){let t=this.instances.get(e);if(t){t.disposed=!0,this.instances.delete(e);try{t.onDispose?.()}catch(e){console.warn(`[memizy host] dispose failed:`,e)}}}getInstance(e){return this.instances.get(e)}start(){if(this.mode===`solo`||this.isStarted&&!this.startPromise)return Promise.resolve();if(!this.settingsOk)return Promise.reject(Error(`The settings are not valid.`));let e=this.roster.length,t=this.runtime.multiplayer.players;return e<t.min||e>t.max?Promise.reject(Error(`This plugin needs ${t.min}–${t.max} players (now ${e}).`)):(this.startPromise??=this.runStart(),this.startPromise)}async runStart(){this.disposeInstance(r.SETTINGS_ADDRESS);let e=Date.now()+(this.options.readyTimeoutMs??15e3),t=()=>[...this.instances.values()].every(e=>e.ready),n=()=>this.instances.get(this.authority)?.ready===!0;for(;!(t()&&n())&&(Date.now()<e||!n());){if(this.isEnded)return;await c(25)}let i=this.options.countdownMs??3e3;for(let e=Math.ceil(i/1e3);e>0;e--)this.emit({type:`countdown`,secondsLeft:e}),await c(Math.min(1e3,i-(Math.ceil(i/1e3)-e)*1e3));this.isEnded||(this.isStarted=!0,this.emit({type:`started`}),await this.callPlugin(this.authority,e=>e.start()))}addPlayer(e){if(this.mode===`solo`)throw Error(`Players cannot join a solo session.`);if(this.roster.some(t=>t.id===e.id)||[r.BOARD_ADDRESS,r.SETTINGS_ADDRESS,`server`].includes(e.id))throw Error(`Player ID "${e.id}" is already used or reserved.`);if(this.isStarted&&!this.runtime.multiplayer.lateJoin)throw Error(`This game does not allow joining after the start.`);if(this.roster.length>=this.runtime.multiplayer.players.max)throw Error(`The game is full.`);this.roster=[...this.roster,{id:e.id,name:e.name,isHost:!1,connected:!0}],this.broadcastPlayers()}removePlayer(e){if(e===this.authority)throw Error(`The host cannot be removed.`);this.disposeInstance(e),this.roster=this.roster.filter(t=>t.id!==e),this.broadcastPlayers()}renamePlayer(e,t){this.roster=this.roster.map(n=>n.id===e?{...n,name:t}:n),this.broadcastPlayers()}setConnected(e,t){let n=this.instances.get(e);n&&(n.connected=t),this.roster.some(t=>t.id===e)&&(this.roster=this.roster.map(n=>n.id===e?{...n,connected:t}:n),this.broadcastPlayers()),e===this.authority?this.setAuthorityConnected(t):t&&n&&this.callPlugin(e,e=>e.authorityChanged({connected:!0}))}get services(){return this.runtime.services.filter(e=>typeof this.options.services?.[e]==`function`)}get paused(){return this.pauseState.pausedAt!==null}get clock(){return{offsetMs:0,...this.pauseState}}pause(){!this.isStarted||this.isEnded||this.paused||(this.pauseState={...this.pauseState,pausedAt:Date.now()},this.broadcastClock())}resume(){this.paused&&(this.pauseState={pausedMs:this.pauseState.pausedMs+Math.max(0,Date.now()-this.pauseState.pausedAt),pausedAt:null},this.broadcastClock())}broadcastClock(){let e=this.clock;for(let t of this.instances.keys())this.callPlugin(t,t=>t.clockChanged(e));this.emit({type:`paused`,paused:this.paused,clock:{...this.pauseState}})}setConfig(e){this.hostConfig={...this.hostConfig,...e};for(let e of this.instances.keys())this.callPlugin(e,e=>e.configChanged(this.hostConfig))}async end(e=`closed`){this.isEnded||(this.isEnded=!0,e===`finished`?await this.discardSnapshot():await this.flushSnapshot(),await this.flushData(),await Promise.all([...this.instances.keys()].map(t=>this.callPlugin(t,t=>t.sessionEnded(e)))))}createHostApi(e){let t=(t,n)=>(async()=>{if(e.disposed)throw new r.ProtocolError(`SESSION_ENDED`,`This instance has been closed.`);if(this.isEnded&&t!==`reportError`)throw new r.ProtocolError(`SESSION_ENDED`,`The session has ended.`);return n()})().catch(n=>{let i=(0,r.toProtocolError)(n);throw this.emit({type:`rejected`,address:e.address,method:t,code:i.code,message:i.detail}),i}),n=()=>e.view===`solo`||e.view===`controller`,s=(t,n)=>{if(!n)throw new r.ProtocolError(`NOT_ALLOWED_IN_VIEW`,`${t} is not available in view "${e.view}".`)},c=t=>{if(e.address!==this.authority)throw new r.ProtocolError(`NOT_AUTHORITY`,`${t} may only be called by the authority.`)};return{hello:(...n)=>t(`hello`,async()=>{let[t]=(0,a.parseHostCall)(`hello`,n);if(e.greeted)throw new r.ProtocolError(`INVALID_ARGUMENT`,`hello may only be called once per instance.`);let i=(0,r.negotiateProtocolVersion)(t.protocol,r.PROTOCOL_VERSION);if(!i)throw new r.ProtocolError(`UNSUPPORTED_PROTOCOL`,`The plugin speaks protocol ${t.protocol}; this host supports ${r.PROTOCOL_VERSION}.`);return e.greeted=!0,t.plugin.id!==this.plugin.manifest.id&&this.emit({type:`pluginError`,address:e.address,code:`PLUGIN_ID_MISMATCH`,message:`The SDK reports plugin id "${t.plugin.id}", the manifest says "${this.plugin.manifest.id}".`}),this.buildInit(e,i)}),ready:(...n)=>t(`ready`,()=>{(0,a.parseHostCall)(`ready`,n),!e.ready&&(e.ready=!0,this.emit({type:`ready`,address:e.address}),e.address===this.authority&&(this.mode===`solo`&&!this.isStarted&&!e.resumed?(this.isStarted=!0,this.emit({type:`started`}),this.callPlugin(e.address,e=>e.start())):this.isStarted&&(this.resumeStartPending&&(this.resumeStartPending=!1,e.resumed||this.callPlugin(e.address,e=>e.start())),this.setAuthorityConnected(!0))))}),send:(...n)=>t(`send`,()=>{let[t]=(0,a.parseHostCall)(`send`,n);s(`send`,e.view!==`settings`);let i=(0,a.assertJsonWithin)(t.data,r.LIMITS.messageBytes,`MESSAGE_TOO_LARGE`,`message`),o=e.address===this.authority?1/Math.max(1,this.instances.size-1):1;if(!e.limiter.tryTake(o))throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.messagesPerSecond} messages per second.`);if(!e.connected)return;if(t.to===`authority`){let n=this.instances.get(this.authority);if(!n||!n.connected||!this.authorityConnected)throw new r.ProtocolError(`AUTHORITY_UNAVAILABLE`,`The host is not reachable right now.`);this.deliver(e.address,this.authority,t.data,i);return}let c=t.to===`all`?[...this.instances.keys()].filter(t=>t!==e.address&&t!==r.SETTINGS_ADDRESS):t.to.filter(e=>e!==r.SETTINGS_ADDRESS);for(let n of c)this.deliver(e.address,n,t.data,i)}),saveSnapshot:(...e)=>t(`saveSnapshot`,()=>{let[t]=(0,a.parseHostCall)(`saveSnapshot`,e);if(c(`saveSnapshot`),(0,a.assertJsonWithin)(t,r.LIMITS.snapshotBytes,`SNAPSHOT_TOO_LARGE`,`snapshot`),this.finished){this.finishedSnapshot=structuredClone(t);return}this.pendingSnapshot=structuredClone(t),this.snapshotTimer??=setTimeout(()=>void this.flushSnapshot(),1e3/r.LIMITS.snapshotsPerSecond)}),recordAnswer:(...n)=>t(`recordAnswer`,async()=>{let[t]=(0,a.parseHostCall)(`recordAnswer`,n),i=this.roster.some(t=>t.id===e.address)?e.address:null,o=t.playerId??i;if(!o)throw new r.ProtocolError(`INVALID_ARGUMENT`,`recordAnswer needs a playerId on the board.`);if(o!==i&&c(`recordAnswer for another player`),!this.roster.some(e=>e.id===o))throw new r.ProtocolError(`INVALID_ARGUMENT`,`Unknown player "${o}".`);let s=e.address===this.authority?1/Math.max(1,this.roster.length):1;if(!e.answerLimiter.tryTake(s))throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.answersPerSecond} answers per second.`);t.answer!==void 0&&(0,a.assertJsonWithin)(t.answer,r.LIMITS.answerBytes,`DATA_TOO_LARGE`,`answer`);let l=this.prepared.set.items.find(e=>e.id===t.itemId),u=e=>Array.isArray(e)?e.filter(e=>typeof e==`string`):[];if(!l){if(!t.item)throw new r.ProtocolError(`INVALID_ARGUMENT`,`Unknown item "${t.itemId}" (attach the item for a generated one).`);(0,a.assertJsonWithin)(t.item,r.LIMITS.generatedItemBytes,`DATA_TOO_LARGE`,`item`),this.emit({type:`answer`,playerId:o,record:null,answer:t,generated:!0,skills:u(t.item.skills),tags:u(t.item.tags)});return}let d=this.userKey(o),f=this.prepared.set.meta.id,p=await this.storage.loadProgress(d,f),{item:m,...h}=t,g=this.algorithm.apply(p[t.itemId],h,new Date,structuredClone(l));await this.storage.saveProgress(d,f,{[t.itemId]:g}),this.emit({type:`answer`,playerId:o,record:g,answer:h,generated:!1,skills:u(l.skills),tags:u(l.tags)})}),saveData:(...i)=>t(`saveData`,()=>{let[t,o]=(0,a.parseHostCall)(`saveData`,i);s(`saveData`,n()),(0,a.assertJsonWithin)(o,r.LIMITS.dataBytes,`DATA_TOO_LARGE`,`plugin data (${t})`);let c=`${e.address}|${t}`,l=this.pendingData.get(c);if(l)l.value=structuredClone(o);else{let n={playerId:e.address,scope:t,value:structuredClone(o),timer:setTimeout(()=>void this.flushData(c),1e3/r.LIMITS.dataWritesPerSecond)};this.pendingData.set(c,n)}}),updateSettings:(...n)=>t(`updateSettings`,()=>{let[t]=(0,a.parseHostCall)(`updateSettings`,n);s(`updateSettings`,e.view===`settings`);let{values:i,errors:o}=(0,r.resolveSettings)(this.runtime.settings,t.values);this.settingValues=i,this.settingsOk=t.valid&&o.length===0;let c=t.message??o[0];this.emit({type:`settings`,values:i,valid:this.settingsOk,...c&&{message:c}})}),getAsset:(...n)=>t(`getAsset`,async()=>{let[t,o]=(0,a.parseHostCall)(`getAsset`,n);if(!e.assetLimiter.tryTake())throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.assetsPerSecond} assets per second.`);let s=o?this.options.set.items.find(e=>e.id===o):void 0,c=(0,i.resolveAsset)(t,s,this.options.set.meta);if(!c)throw new r.ProtocolError(`ASSET_NOT_FOUND`,`Asset "${t}" does not exist.`);if(this.options.loadAsset)return this.options.loadAsset(t,o);if(/^https?:/i.test(c.value)){let e=await fetch(c.value);if(e.ok)return e.blob()}throw new r.ProtocolError(`ASSET_NOT_FOUND`,`Asset "${t}" cannot be loaded by this host.`)}),end:(...e)=>t(`end`,async()=>{let[t]=(0,a.parseHostCall)(`end`,e);c(`end`),!this.hasResult&&(this.hasResult=!0,await this.discardSnapshot(),this.emit({type:`result`,result:t}))}),reportError:(...n)=>t(`reportError`,()=>{let[t]=(0,a.parseHostCall)(`reportError`,n);e.errorLimiter.tryTake()&&this.emit({type:`pluginError`,address:e.address,code:t.code,message:t.message,...t.context&&{context:t.context}})}),service:(...n)=>t(`service`,async()=>{let[t,i]=(0,a.parseHostCall)(`service`,n),s=this.services.includes(t)?this.options.services[t]:void 0;if(!s)throw new r.ProtocolError(`SERVICE_UNAVAILABLE`,`Service "${t}" is not available here (declare it in the manifest "services"; this host offers: ${this.services.join(`, `)||`none`}).`);if((0,a.assertJsonWithin)(i,r.LIMITS.serviceBytes,`MESSAGE_TOO_LARGE`,`service ${t} request`),!e.serviceLimiter.tryTake())throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.servicesPerMinute} service calls per minute.`);let c=this.roster.some(t=>t.id===e.address)?e.address:null,l=await s(o(i),{playerId:c,address:e.address,pluginId:this.plugin.manifest.id,setId:this.prepared.set.meta.id});return(0,a.assertJsonWithin)(l??null,r.LIMITS.serviceResultBytes,`MESSAGE_TOO_LARGE`,`service ${t} result`),o(l??null)})}}async buildInit(e,t){let n=e.view===`solo`||e.view===`controller`,r=this.prepared.set.meta.id,i=n?this.userKey(e.address):null,a=this.plugin.manifest.id;e.address===this.authority&&await this.flushSnapshot();let o=e.address===this.authority?this.finished?structuredClone(this.finishedSnapshot):await this.storage.loadSnapshot(this.id):null;return e.resumed=o!==null,{protocol:t,host:this.options.host??{name:`@memizy/host-sdk`,version:`1.0.0-rc.6`},oqseVersion:`1.0`,features:[],session:{id:this.id,mode:this.mode,hostAs:this.hostAs,view:e.view,self:e.address,authority:this.authority,lateJoin:this.isStarted&&e.view!==`settings`},players:this.roster,set:structuredClone(this.mode===`multiplayer`&&e.address!==this.authority?this.prepared.publicSet:this.prepared.set),settings:{...this.settingValues},config:this.hostConfig,clock:this.clock,services:this.services,progress:i?await this.storage.loadProgress(i,r):{},data:i?{plugin:this.pendingDataValue(e.address,`plugin`)??await this.storage.loadData(i,a,`plugin`,r)??null,set:this.pendingDataValue(e.address,`set`)??await this.storage.loadData(i,a,`set`,r)??null}:{plugin:null,set:null},snapshot:o}}userKey(e){return this.options.userKeyFor?.(e)??e}deliver(e,t,n,r){let i=this.instances.get(t);if(!i||!i.connected||!i.plugin)return;this.emit({type:`traffic`,from:e,to:t,bytes:r,data:n});let a=structuredClone(n);setTimeout(()=>void this.callPlugin(t,t=>t.deliver({from:e,data:a,sentAt:Date.now()})),0)}broadcastPlayers(){this.emit({type:`players`,players:this.roster});for(let e of this.instances.keys())this.callPlugin(e,e=>e.playersChanged(this.roster))}setAuthorityConnected(e){if(this.authorityConnected!==e){this.authorityConnected=e,this.emit({type:`authority`,connected:e});for(let t of this.instances.keys())t!==this.authority&&t!==r.SETTINGS_ADDRESS&&this.callPlugin(t,t=>t.authorityChanged({connected:e}))}}async callPlugin(e,t){let n=this.instances.get(e);if(n?.plugin&&!n.disposed)try{await t(n.plugin)}catch(t){this.emit({type:`pluginError`,address:e,code:`PLUGIN_CALL_FAILED`,message:(0,r.toProtocolError)(t).detail})}}async discardSnapshot(){this.finished||(this.finished=!0,clearTimeout(this.snapshotTimer),this.snapshotTimer=void 0,this.finishedSnapshot=this.pendingSnapshot===void 0?await this.storage.loadSnapshot(this.id):this.pendingSnapshot,this.pendingSnapshot=void 0,await this.storage.deleteSnapshot?.(this.id))}async flushSnapshot(){if(clearTimeout(this.snapshotTimer),this.snapshotTimer=void 0,this.pendingSnapshot===void 0||this.finished)return;let e=this.pendingSnapshot;this.pendingSnapshot=void 0,await this.storage.saveSnapshot(this.id,e),this.finished&&await this.storage.deleteSnapshot?.(this.id)}pendingDataValue(e,t){return this.pendingData.get(`${e}|${t}`)?.value}async flushData(e){let t=e?[[e,this.pendingData.get(e)]]:[...this.pendingData.entries()];for(let[e,n]of t)n&&(clearTimeout(n.timer),this.pendingData.delete(e),await this.storage.saveData(this.userKey(n.playerId),this.plugin.manifest.id,n.scope,this.prepared.set.meta.id,n.value))}};function c(e){return new Promise(t=>setTimeout(t,Math.max(0,e)))}exports.LocalSession=s;
1
+ require("./version.cjs");const e=require("./plugin.cjs"),t=require("./learning.cjs"),n=require("./storage.cjs");let r=require("@memizy/protocol"),i=require("@memizy/oqse"),a=require("@memizy/protocol/internal");function o(e){try{return structuredClone(e)}catch{return JSON.parse(JSON.stringify(e))}}var s=class{id;seed;plugin;runtime;mode;hostAs;authority;prepared;roster;settingValues;settingsOk=!0;hostConfig;isStarted=!1;isEnded=!1;pauseState={pausedMs:0,pausedAt:null};options;storage;algorithm;instances=new Map;listeners=new Set;authorityConnected=!0;startPromise=null;resumeStartPending=!1;pendingSnapshot=void 0;finished=!1;hasResult=!1;finishedSnapshot=null;snapshotTimer;pendingData=new Map;get players(){return this.roster}get settings(){return this.settingValues}get settingsValid(){return this.settingsOk}get config(){return this.hostConfig}get started(){return this.isStarted}get ended(){return this.isEnded}constructor(i){if(this.options=i,this.id=i.sessionId??`session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`,this.seed=i.seed??l(),this.plugin=i.plugin,this.runtime=i.plugin.runtime,this.mode=i.mode,this.storage=i.storage??new n.MemoryStorage,this.algorithm=i.algorithm??t.leitner,this.hostConfig={locale:i.locale??`en`,theme:i.theme??`light`},i.mode===`solo`){if(!this.runtime.solo)throw Error(`The plugin does not support solo mode.`);this.hostAs=null}else{if(!this.runtime.multiplayer)throw Error(`The plugin does not support multiplayer.`);let e=i.hostAs??this.runtime.multiplayer.hostAs[0];if(!this.runtime.multiplayer.hostAs.includes(e))throw Error(`The plugin does not support hostAs "${e}".`);this.hostAs=e}let a=i.players??(i.mode===`solo`?[{id:`player`,name:`Player`,isHost:!0}]:[]);if(i.mode===`solo`&&a.length!==1)throw Error(`A solo session has exactly one player.`);if(new Set(a.map(e=>e.id)).size!==a.length)throw Error(`Player IDs must be unique.`);if(a.some(e=>e.id===r.BOARD_ADDRESS||e.id===r.SETTINGS_ADDRESS||e.id===`server`))throw Error(`Player IDs "board", "settings" and "server" are reserved.`);if(this.roster=a.map(e=>({id:e.id,name:e.name,isHost:i.mode===`solo`?!0:e.isHost??!1,connected:!0})),i.mode===`solo`)this.authority=this.roster[0].id;else if(this.hostAs===`presenter`)this.authority=r.BOARD_ADDRESS;else{let e=this.roster.find(e=>e.isHost);if(!e)throw Error(`With hostAs "player", one player must have isHost: true.`);this.authority=e.id}let s=(0,r.resolveSettings)(this.runtime.settings,i.settings??{},this.runtime.unknownSettings);if(s.errors.length>0)throw Error(`Invalid settings: ${s.errors.join(`; `)}`);this.settingValues=s.values;let c=i.shuffleSeed===null?void 0:i.shuffleSeed??l();this.prepared=e.prepareSetForPlugin(o(i.set),i.plugin.manifest,{seed:c}),i.restore&&(this.isStarted=!0,this.resumeStartPending=!0),i.clock&&(this.pauseState={pausedMs:i.clock.pausedMs??0,pausedAt:i.clock.pausedAt??null})}on(e){return this.listeners.add(e),()=>this.listeners.delete(e)}emit(e){for(let t of this.listeners)try{t(e)}catch(e){console.error(`[memizy host] session listener failed:`,e)}}viewOf(e){return e===r.SETTINGS_ADDRESS?`settings`:this.mode===`solo`?`solo`:e===r.BOARD_ADDRESS?`board`:`controller`}addresses(){return[...this.hostAs===`presenter`?[r.BOARD_ADDRESS]:[],...this.roster.map(e=>e.id)]}async connect(e,t,n={}){let i=this.viewOf(e);if(i===`settings`){if(!this.runtime.multiplayer||!this.runtime.settingsScreen)throw Error(`The plugin has no settings screen (settingsScreen).`);if(this.isStarted)throw Error(`The settings screen is only available before the game starts.`)}else if(!this.addresses().includes(e))throw Error(`Unknown address "${e}" in this session.`);if(this.isEnded)throw Error(`The session has ended.`);this.disposeInstance(e);let o={address:e,view:i,hostApi:void 0,endpoint:t,onDispose:n.onDispose,plugin:null,ready:!1,connected:!0,disposed:!1,resumed:!1,greeted:!1,limiter:new a.RateLimiter(r.LIMITS.messagesPerSecond,r.LIMITS.messageBurst),serviceLimiter:new a.RateLimiter(r.LIMITS.servicesPerMinute/60,10),answerLimiter:new a.RateLimiter(r.LIMITS.answersPerSecond,r.LIMITS.answerBurst),assetLimiter:new a.RateLimiter(r.LIMITS.assetsPerSecond,r.LIMITS.assetBurst),errorLimiter:new a.RateLimiter(r.LIMITS.errorReportsPerSecond,r.LIMITS.errorReportBurst)};o.hostApi=this.createHostApi(o),this.instances.set(e,o),e===this.authority&&this.isStarted&&this.setAuthorityConnected(!1);try{o.plugin=await t(o.hostApi)}catch(e){if(o.disposed)return o;throw e}return o}async reload(e){let t=this.instances.get(e);if(!t)throw Error(`No instance at "${e}".`);return e===this.authority&&await this.flushSnapshot(),this.connect(e,t.endpoint,{onDispose:t.onDispose})}disconnect(e){this.disposeInstance(e),e===this.authority&&this.isStarted&&this.setAuthorityConnected(!1)}disposeInstance(e){let t=this.instances.get(e);if(t){t.disposed=!0,this.instances.delete(e);try{t.onDispose?.()}catch(e){console.warn(`[memizy host] dispose failed:`,e)}}}getInstance(e){return this.instances.get(e)}start(){if(this.mode===`solo`||this.isStarted&&!this.startPromise)return Promise.resolve();if(!this.settingsOk)return Promise.reject(Error(`The settings are not valid.`));let e=this.roster.length,t=this.runtime.multiplayer.players;return e<t.min||e>t.max?Promise.reject(Error(`This plugin needs ${t.min}–${t.max} players (now ${e}).`)):(this.startPromise??=this.runStart(),this.startPromise)}async runStart(){this.disposeInstance(r.SETTINGS_ADDRESS);let e=Date.now()+(this.options.readyTimeoutMs??15e3),t=()=>[...this.instances.values()].every(e=>e.ready),n=()=>this.instances.get(this.authority)?.ready===!0;for(;!(t()&&n())&&(Date.now()<e||!n());){if(this.isEnded)return;await c(25)}let i=this.options.countdownMs??3e3;for(let e=Math.ceil(i/1e3);e>0;e--)this.emit({type:`countdown`,secondsLeft:e}),await c(Math.min(1e3,i-(Math.ceil(i/1e3)-e)*1e3));this.isEnded||(this.isStarted=!0,this.emit({type:`started`}),await this.callPlugin(this.authority,e=>e.start()))}addPlayer(e){if(this.mode===`solo`)throw Error(`Players cannot join a solo session.`);if(this.roster.some(t=>t.id===e.id)||[r.BOARD_ADDRESS,r.SETTINGS_ADDRESS,`server`].includes(e.id))throw Error(`Player ID "${e.id}" is already used or reserved.`);if(this.isStarted&&!this.runtime.multiplayer.lateJoin)throw Error(`This game does not allow joining after the start.`);if(this.roster.length>=this.runtime.multiplayer.players.max)throw Error(`The game is full.`);this.roster=[...this.roster,{id:e.id,name:e.name,isHost:!1,connected:!0}],this.broadcastPlayers()}removePlayer(e){if(e===this.authority)throw Error(`The host cannot be removed.`);this.disposeInstance(e),this.roster=this.roster.filter(t=>t.id!==e),this.broadcastPlayers()}renamePlayer(e,t){this.roster=this.roster.map(n=>n.id===e?{...n,name:t}:n),this.broadcastPlayers()}setConnected(e,t){let n=this.instances.get(e);n&&(n.connected=t),this.roster.some(t=>t.id===e)&&(this.roster=this.roster.map(n=>n.id===e?{...n,connected:t}:n),this.broadcastPlayers()),e===this.authority?this.setAuthorityConnected(t):t&&n&&this.callPlugin(e,e=>e.authorityChanged({connected:!0}))}get services(){return this.runtime.services.filter(e=>typeof this.options.services?.[e]==`function`)}get paused(){return this.pauseState.pausedAt!==null}get clock(){return{offsetMs:0,...this.pauseState}}pause(){!this.isStarted||this.isEnded||this.paused||(this.pauseState={...this.pauseState,pausedAt:Date.now()},this.broadcastClock())}resume(){this.paused&&(this.pauseState={pausedMs:this.pauseState.pausedMs+Math.max(0,Date.now()-this.pauseState.pausedAt),pausedAt:null},this.broadcastClock())}broadcastClock(){let e=this.clock;for(let t of this.instances.keys())this.callPlugin(t,t=>t.clockChanged(e));this.emit({type:`paused`,paused:this.paused,clock:{...this.pauseState}})}setConfig(e){this.hostConfig={...this.hostConfig,...e};for(let e of this.instances.keys())this.callPlugin(e,e=>e.configChanged(this.hostConfig))}async end(e=`closed`){this.isEnded||(this.isEnded=!0,e===`finished`?await this.discardSnapshot():await this.flushSnapshot(),await this.flushData(),await Promise.all([...this.instances.keys()].map(t=>this.callPlugin(t,t=>t.sessionEnded(e)))))}createHostApi(e){let t=(t,n)=>(async()=>{if(e.disposed)throw new r.ProtocolError(`SESSION_ENDED`,`This instance has been closed.`);if(this.isEnded&&t!==`reportError`)throw new r.ProtocolError(`SESSION_ENDED`,`The session has ended.`);return n()})().catch(n=>{let i=(0,r.toProtocolError)(n);throw this.emit({type:`rejected`,address:e.address,method:t,code:i.code,message:i.detail}),i}),n=()=>e.view===`solo`||e.view===`controller`,s=(t,n)=>{if(!n)throw new r.ProtocolError(`NOT_ALLOWED_IN_VIEW`,`${t} is not available in view "${e.view}".`)},c=t=>{if(e.address!==this.authority)throw new r.ProtocolError(`NOT_AUTHORITY`,`${t} may only be called by the authority.`)};return{hello:(...n)=>t(`hello`,async()=>{let[t]=(0,a.parseHostCall)(`hello`,n);if(e.greeted)throw new r.ProtocolError(`INVALID_ARGUMENT`,`hello may only be called once per instance.`);let i=(0,r.negotiateProtocolVersion)(t.protocol,r.PROTOCOL_VERSION);if(!i)throw new r.ProtocolError(`UNSUPPORTED_PROTOCOL`,`The plugin speaks protocol ${t.protocol}; this host supports ${r.PROTOCOL_VERSION}.`);return e.greeted=!0,t.plugin.id!==this.plugin.manifest.id&&this.emit({type:`pluginError`,address:e.address,code:`PLUGIN_ID_MISMATCH`,message:`The SDK reports plugin id "${t.plugin.id}", the manifest says "${this.plugin.manifest.id}".`}),this.buildInit(e,i)}),ready:(...n)=>t(`ready`,()=>{(0,a.parseHostCall)(`ready`,n),!e.ready&&(e.ready=!0,this.emit({type:`ready`,address:e.address}),e.address===this.authority&&(this.mode===`solo`&&!this.isStarted&&!e.resumed?(this.isStarted=!0,this.emit({type:`started`}),this.callPlugin(e.address,e=>e.start())):this.isStarted&&(this.resumeStartPending&&(this.resumeStartPending=!1,e.resumed||this.callPlugin(e.address,e=>e.start())),this.setAuthorityConnected(!0))))}),send:(...n)=>t(`send`,()=>{let[t]=(0,a.parseHostCall)(`send`,n);s(`send`,e.view!==`settings`);let i=(0,a.assertJsonWithin)(t.data,r.LIMITS.messageBytes,`MESSAGE_TOO_LARGE`,`message`),o=e.address===this.authority?1/Math.max(1,this.instances.size-1):1;if(!e.limiter.tryTake(o))throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.messagesPerSecond} messages per second.`);if(!e.connected)return;if(t.to===`authority`){let n=this.instances.get(this.authority);if(!n||!n.connected||!this.authorityConnected)throw new r.ProtocolError(`AUTHORITY_UNAVAILABLE`,`The host is not reachable right now.`);this.deliver(e.address,this.authority,t.data,i);return}let c=t.to===`all`?[...this.instances.keys()].filter(t=>t!==e.address&&t!==r.SETTINGS_ADDRESS):t.to.filter(e=>e!==r.SETTINGS_ADDRESS);for(let n of c)this.deliver(e.address,n,t.data,i)}),saveSnapshot:(...e)=>t(`saveSnapshot`,()=>{let[t]=(0,a.parseHostCall)(`saveSnapshot`,e);if(c(`saveSnapshot`),(0,a.assertJsonWithin)(t,r.LIMITS.snapshotBytes,`SNAPSHOT_TOO_LARGE`,`snapshot`),this.finished){this.finishedSnapshot=structuredClone(t);return}this.pendingSnapshot=structuredClone(t),this.snapshotTimer??=setTimeout(()=>void this.flushSnapshot(),1e3/r.LIMITS.snapshotsPerSecond)}),recordAnswer:(...n)=>t(`recordAnswer`,async()=>{let[t]=(0,a.parseHostCall)(`recordAnswer`,n),i=this.roster.some(t=>t.id===e.address)?e.address:null,o=t.playerId??i;if(!o)throw new r.ProtocolError(`INVALID_ARGUMENT`,`recordAnswer needs a playerId on the board.`);if(c(`recordAnswer`),!this.roster.some(e=>e.id===o))throw new r.ProtocolError(`INVALID_ARGUMENT`,`Unknown player "${o}".`);let s=e.address===this.authority?1/Math.max(1,this.roster.length):1;if(!e.answerLimiter.tryTake(s))throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.answersPerSecond} answers per second.`);t.answer!==void 0&&(0,a.assertJsonWithin)(t.answer,r.LIMITS.answerBytes,`DATA_TOO_LARGE`,`answer`);let l=this.prepared.set.items.find(e=>e.id===t.itemId),u=e=>Array.isArray(e)?e.filter(e=>typeof e==`string`):[];if(!l){if(!t.item)throw new r.ProtocolError(`INVALID_ARGUMENT`,`Unknown item "${t.itemId}" (attach the item for a generated one).`);(0,a.assertJsonWithin)(t.item,r.LIMITS.generatedItemBytes,`DATA_TOO_LARGE`,`item`),this.emit({type:`answer`,playerId:o,setId:this.prepared.set.meta.id,record:null,answer:t,generated:!0,ungraded:!1,skills:u(t.item.skills),tags:u(t.item.tags)});return}let d=this.prepared.set.meta.id;if(l.type===`open-ended`){let{item:e,...n}=t;this.emit({type:`answer`,playerId:o,setId:d,record:null,answer:n,generated:!1,ungraded:!0,skills:u(l.skills),tags:u(l.tags)});return}let f=this.userKey(o),p=await this.storage.loadProgress(f,d),{item:m,...h}=t,g=this.algorithm.apply(p[t.itemId],h,new Date,structuredClone(l));await this.storage.saveProgress(f,d,{[t.itemId]:g}),this.emit({type:`answer`,playerId:o,setId:d,record:g,answer:h,generated:!1,ungraded:!1,skills:u(l.skills),tags:u(l.tags)})}),saveData:(...i)=>t(`saveData`,()=>{let[t,o]=(0,a.parseHostCall)(`saveData`,i);s(`saveData`,n()),(0,a.assertJsonWithin)(o,r.LIMITS.dataBytes,`DATA_TOO_LARGE`,`plugin data (${t})`);let c=`${e.address}|${t}`,l=this.pendingData.get(c);if(l)l.value=structuredClone(o);else{let n={playerId:e.address,scope:t,value:structuredClone(o),timer:setTimeout(()=>void this.flushData(c),1e3/r.LIMITS.dataWritesPerSecond)};this.pendingData.set(c,n)}}),updateSettings:(...n)=>t(`updateSettings`,()=>{let[t]=(0,a.parseHostCall)(`updateSettings`,n);s(`updateSettings`,e.view===`settings`);let{values:i,errors:o}=(0,r.resolveSettings)(this.runtime.settings,t.values,this.runtime.unknownSettings);this.settingValues=i,this.settingsOk=t.valid&&o.length===0;let c=t.message??o[0];this.emit({type:`settings`,values:i,valid:this.settingsOk,...c&&{message:c}})}),getAsset:(...n)=>t(`getAsset`,async()=>{let[t,o]=(0,a.parseHostCall)(`getAsset`,n);if(!e.assetLimiter.tryTake())throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.assetsPerSecond} assets per second.`);let s=this.mode===`multiplayer`&&e.address!==this.authority?this.prepared.publicSet:this.prepared.set,c=o?s.items.find(e=>e.id===o):void 0,l=(0,i.resolveAsset)(t,c,s.meta);if(!l)throw new r.ProtocolError(`ASSET_NOT_FOUND`,`Asset "${t}" does not exist.`);if(this.options.loadAsset)return this.options.loadAsset(t,o);if(/^https?:/i.test(l.value)){let e=await fetch(l.value);if(e.ok)return e.blob()}throw new r.ProtocolError(`ASSET_NOT_FOUND`,`Asset "${t}" cannot be loaded by this host.`)}),end:(...e)=>t(`end`,async()=>{let[t]=(0,a.parseHostCall)(`end`,e);c(`end`),!this.hasResult&&(this.hasResult=!0,await this.discardSnapshot(),this.emit({type:`result`,result:t}))}),reportError:(...n)=>t(`reportError`,()=>{let[t]=(0,a.parseHostCall)(`reportError`,n);e.errorLimiter.tryTake()&&this.emit({type:`pluginError`,address:e.address,code:t.code,message:t.message,...t.context&&{context:t.context}})}),service:(...n)=>t(`service`,async()=>{let[t,i]=(0,a.parseHostCall)(`service`,n),s=this.services.includes(t)?this.options.services[t]:void 0;if(!s)throw new r.ProtocolError(`SERVICE_UNAVAILABLE`,`Service "${t}" is not available here (declare it in the manifest "services"; this host offers: ${this.services.join(`, `)||`none`}).`);if((0,a.assertJsonWithin)(i,r.LIMITS.serviceBytes,`MESSAGE_TOO_LARGE`,`service ${t} request`),!e.serviceLimiter.tryTake())throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.servicesPerMinute} service calls per minute.`);let c=this.roster.some(t=>t.id===e.address)?e.address:null,l=await s(o(i),{playerId:c,address:e.address,pluginId:this.plugin.manifest.id,setId:this.prepared.set.meta.id});return(0,a.assertJsonWithin)(l??null,r.LIMITS.serviceResultBytes,`MESSAGE_TOO_LARGE`,`service ${t} result`),o(l??null)})}}async buildInit(e,t){let n=e.view===`solo`||e.view===`controller`,r=this.prepared.set.meta.id,i=n?this.userKey(e.address):null,a=this.plugin.manifest.id;e.address===this.authority&&await this.flushSnapshot();let o=e.address===this.authority?this.finished?structuredClone(this.finishedSnapshot):await this.storage.loadSnapshot(this.id):null;return e.resumed=o!==null,{protocol:t,host:this.options.host??{name:`@memizy/host-sdk`,version:`1.0.0-rc.7`},oqseVersion:`1.0`,features:[],session:{id:this.id,mode:this.mode,hostAs:this.hostAs,view:e.view,self:e.address,authority:this.authority,lateJoin:this.isStarted&&e.view!==`settings`},players:this.roster,set:structuredClone(this.mode===`multiplayer`&&e.address!==this.authority?this.prepared.publicSet:this.prepared.set),settings:{...this.settingValues},config:this.hostConfig,clock:this.clock,services:this.services,progress:i?await this.storage.loadProgress(i,r):{},data:i?{plugin:this.pendingDataValue(e.address,`plugin`)??await this.storage.loadData(i,a,`plugin`,r)??null,set:this.pendingDataValue(e.address,`set`)??await this.storage.loadData(i,a,`set`,r)??null}:{plugin:null,set:null},snapshot:o,...e.address===this.authority&&{seed:this.seed}}}userKey(e){return this.options.userKeyFor?.(e)??e}deliver(e,t,n,r){let i=this.instances.get(t);if(!i||!i.connected||!i.plugin)return;this.emit({type:`traffic`,from:e,to:t,bytes:r,data:n});let a=structuredClone(n);setTimeout(()=>void this.callPlugin(t,t=>t.deliver({from:e,data:a,sentAt:Date.now()})),0)}broadcastPlayers(){this.emit({type:`players`,players:this.roster});for(let e of this.instances.keys())this.callPlugin(e,e=>e.playersChanged(this.roster))}setAuthorityConnected(e){if(this.authorityConnected!==e){this.authorityConnected=e,this.emit({type:`authority`,connected:e});for(let t of this.instances.keys())t!==this.authority&&t!==r.SETTINGS_ADDRESS&&this.callPlugin(t,t=>t.authorityChanged({connected:e}))}}async callPlugin(e,t){let n=this.instances.get(e);if(n?.plugin&&!n.disposed)try{await t(n.plugin)}catch(t){this.emit({type:`pluginError`,address:e,code:`PLUGIN_CALL_FAILED`,message:(0,r.toProtocolError)(t).detail})}}async discardSnapshot(){this.finished||(this.finished=!0,clearTimeout(this.snapshotTimer),this.snapshotTimer=void 0,this.finishedSnapshot=this.pendingSnapshot===void 0?await this.storage.loadSnapshot(this.id):this.pendingSnapshot,this.pendingSnapshot=void 0,await this.storage.deleteSnapshot?.(this.id))}async flushSnapshot(){if(clearTimeout(this.snapshotTimer),this.snapshotTimer=void 0,this.pendingSnapshot===void 0||this.finished)return;let e=this.pendingSnapshot;this.pendingSnapshot=void 0,await this.storage.saveSnapshot(this.id,e),this.finished&&await this.storage.deleteSnapshot?.(this.id)}pendingDataValue(e,t){return this.pendingData.get(`${e}|${t}`)?.value}async flushData(e){let t=e?[[e,this.pendingData.get(e)]]:[...this.pendingData.entries()];for(let[e,n]of t)n&&(clearTimeout(n.timer),this.pendingData.delete(e),await this.storage.saveData(this.userKey(n.playerId),this.plugin.manifest.id,n.scope,this.prepared.set.meta.id,n.value))}};function c(e){return new Promise(t=>setTimeout(t,Math.max(0,e)))}function l(){let e=new Uint8Array(16);return globalThis.crypto.getRandomValues(e),[...e].map(e=>e.toString(16).padStart(2,`0`)).join(``)}exports.LocalSession=s;
package/dist/session.js CHANGED
@@ -15,6 +15,7 @@ function h(e) {
15
15
  }
16
16
  var g = class {
17
17
  id;
18
+ seed;
18
19
  plugin;
19
20
  runtime;
20
21
  mode;
@@ -64,7 +65,7 @@ var g = class {
64
65
  return this.isEnded;
65
66
  }
66
67
  constructor(i) {
67
- if (this.options = i, this.id = i.sessionId ?? `session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, this.plugin = i.plugin, this.runtime = i.plugin.runtime, this.mode = i.mode, this.storage = i.storage ?? new n(), this.algorithm = i.algorithm ?? t, this.hostConfig = {
68
+ if (this.options = i, this.id = i.sessionId ?? `session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, this.seed = i.seed ?? v(), this.plugin = i.plugin, this.runtime = i.plugin.runtime, this.mode = i.mode, this.storage = i.storage ?? new n(), this.algorithm = i.algorithm ?? t, this.hostConfig = {
68
69
  locale: i.locale ?? "en",
69
70
  theme: i.theme ?? "light"
70
71
  }, i.mode === "solo") {
@@ -96,10 +97,10 @@ var g = class {
96
97
  if (!e) throw Error("With hostAs \"player\", one player must have isHost: true.");
97
98
  this.authority = e.id;
98
99
  }
99
- let o = l(this.runtime.settings, i.settings ?? {});
100
+ let o = l(this.runtime.settings, i.settings ?? {}, this.runtime.unknownSettings);
100
101
  if (o.errors.length > 0) throw Error(`Invalid settings: ${o.errors.join("; ")}`);
101
102
  this.settingValues = o.values;
102
- let c = i.shuffleSeed === null ? void 0 : i.shuffleSeed ?? this.id;
103
+ let c = i.shuffleSeed === null ? void 0 : i.shuffleSeed ?? v();
103
104
  this.prepared = e(h(i.set), i.plugin.manifest, { seed: c }), i.restore && (this.isStarted = !0, this.resumeStartPending = !0), i.clock && (this.pauseState = {
104
105
  pausedMs: i.clock.pausedMs ?? 0,
105
106
  pausedAt: i.clock.pausedAt ?? null
@@ -338,7 +339,7 @@ var g = class {
338
339
  recordAnswer: (...n) => t("recordAnswer", async () => {
339
340
  let [t] = m("recordAnswer", n), r = this.roster.some((t) => t.id === e.address) ? e.address : null, a = t.playerId ?? r;
340
341
  if (!a) throw new o("INVALID_ARGUMENT", "recordAnswer needs a playerId on the board.");
341
- if (a !== r && f("recordAnswer for another player"), !this.roster.some((e) => e.id === a)) throw new o("INVALID_ARGUMENT", `Unknown player "${a}".`);
342
+ if (f("recordAnswer"), !this.roster.some((e) => e.id === a)) throw new o("INVALID_ARGUMENT", `Unknown player "${a}".`);
342
343
  let s = e.address === this.authority ? 1 / Math.max(1, this.roster.length) : 1;
343
344
  if (!e.answerLimiter.tryTake(s)) throw new o("RATE_LIMITED", `More than ${i.answersPerSecond} answers per second.`);
344
345
  t.answer !== void 0 && p(t.answer, i.answerBytes, "DATA_TOO_LARGE", "answer");
@@ -348,21 +349,41 @@ var g = class {
348
349
  p(t.item, i.generatedItemBytes, "DATA_TOO_LARGE", "item"), this.emit({
349
350
  type: "answer",
350
351
  playerId: a,
352
+ setId: this.prepared.set.meta.id,
351
353
  record: null,
352
354
  answer: t,
353
355
  generated: !0,
356
+ ungraded: !1,
354
357
  skills: l(t.item.skills),
355
358
  tags: l(t.item.tags)
356
359
  });
357
360
  return;
358
361
  }
359
- let u = this.userKey(a), d = this.prepared.set.meta.id, h = await this.storage.loadProgress(u, d), { item: g, ..._ } = t, v = this.algorithm.apply(h[t.itemId], _, /* @__PURE__ */ new Date(), structuredClone(c));
360
- await this.storage.saveProgress(u, d, { [t.itemId]: v }), this.emit({
362
+ let u = this.prepared.set.meta.id;
363
+ if (c.type === "open-ended") {
364
+ let { item: e, ...n } = t;
365
+ this.emit({
366
+ type: "answer",
367
+ playerId: a,
368
+ setId: u,
369
+ record: null,
370
+ answer: n,
371
+ generated: !1,
372
+ ungraded: !0,
373
+ skills: l(c.skills),
374
+ tags: l(c.tags)
375
+ });
376
+ return;
377
+ }
378
+ let d = this.userKey(a), h = await this.storage.loadProgress(d, u), { item: g, ..._ } = t, v = this.algorithm.apply(h[t.itemId], _, /* @__PURE__ */ new Date(), structuredClone(c));
379
+ await this.storage.saveProgress(d, u, { [t.itemId]: v }), this.emit({
361
380
  type: "answer",
362
381
  playerId: a,
382
+ setId: u,
363
383
  record: v,
364
384
  answer: _,
365
385
  generated: !1,
386
+ ungraded: !1,
366
387
  skills: l(c.skills),
367
388
  tags: l(c.tags)
368
389
  });
@@ -385,7 +406,7 @@ var g = class {
385
406
  updateSettings: (...n) => t("updateSettings", () => {
386
407
  let [t] = m("updateSettings", n);
387
408
  r("updateSettings", e.view === "settings");
388
- let { values: i, errors: a } = l(this.runtime.settings, t.values);
409
+ let { values: i, errors: a } = l(this.runtime.settings, t.values, this.runtime.unknownSettings);
389
410
  this.settingValues = i, this.settingsOk = t.valid && a.length === 0;
390
411
  let o = t.message ?? a[0];
391
412
  this.emit({
@@ -398,11 +419,11 @@ var g = class {
398
419
  getAsset: (...n) => t("getAsset", async () => {
399
420
  let [t, r] = m("getAsset", n);
400
421
  if (!e.assetLimiter.tryTake()) throw new o("RATE_LIMITED", `More than ${i.assetsPerSecond} assets per second.`);
401
- let a = r ? this.options.set.items.find((e) => e.id === r) : void 0, s = d(t, a, this.options.set.meta);
402
- if (!s) throw new o("ASSET_NOT_FOUND", `Asset "${t}" does not exist.`);
422
+ let a = this.mode === "multiplayer" && e.address !== this.authority ? this.prepared.publicSet : this.prepared.set, s = r ? a.items.find((e) => e.id === r) : void 0, c = d(t, s, a.meta);
423
+ if (!c) throw new o("ASSET_NOT_FOUND", `Asset "${t}" does not exist.`);
403
424
  if (this.options.loadAsset) return this.options.loadAsset(t, r);
404
- if (/^https?:/i.test(s.value)) {
405
- let e = await fetch(s.value);
425
+ if (/^https?:/i.test(c.value)) {
426
+ let e = await fetch(c.value);
406
427
  if (e.ok) return e.blob();
407
428
  }
408
429
  throw new o("ASSET_NOT_FOUND", `Asset "${t}" cannot be loaded by this host.`);
@@ -446,7 +467,7 @@ var g = class {
446
467
  protocol: t,
447
468
  host: this.options.host ?? {
448
469
  name: "@memizy/host-sdk",
449
- version: "1.0.0-rc.6"
470
+ version: "1.0.0-rc.7"
450
471
  },
451
472
  oqseVersion: "1.0",
452
473
  features: [],
@@ -473,7 +494,8 @@ var g = class {
473
494
  plugin: null,
474
495
  set: null
475
496
  },
476
- snapshot: o
497
+ snapshot: o,
498
+ ...e.address === this.authority && { seed: this.seed }
477
499
  };
478
500
  }
479
501
  userKey(e) {
@@ -544,5 +566,9 @@ var g = class {
544
566
  function _(e) {
545
567
  return new Promise((t) => setTimeout(t, Math.max(0, e)));
546
568
  }
569
+ function v() {
570
+ let e = /* @__PURE__ */ new Uint8Array(16);
571
+ return globalThis.crypto.getRandomValues(e), [...e].map((e) => e.toString(16).padStart(2, "0")).join("");
572
+ }
547
573
  //#endregion
548
574
  export { g as LocalSession };
@@ -6,6 +6,8 @@ export interface LearningAlgorithm {
6
6
  /**
7
7
  * The new progress record of the answered item. `item` is the item of the set with its
8
8
  * answers (type, difficulty, skills… for algorithms like FSRS); it may be ignored.
9
+ * Build it from `previous`: the learner's `starred` and fields of a later OQSEP version
10
+ * MUST stay (OQSEP "On unknown keys"); store `answer.answer` as `lastAnswer.answer`.
9
11
  */
10
12
  apply(previous: ProgressRecord | undefined, answer: AnswerRecord, now: Date, item: OQSEAnyItem): ProgressRecord;
11
13
  }
@@ -35,7 +35,7 @@ export interface PreparedSet {
35
35
  /**
36
36
  * Keeps only the item types the plugin declares and reports the OQSEM
37
37
  * handshake (`checkCompatibility`). The set should already be loaded with
38
- * `loadOQSEFile`. With a `seed` (normally the session id) the options, pairs
38
+ * `loadOQSEFile`. With a secret `seed` (random per game, never the session id) the options, pairs
39
39
  * and orders are shuffled once for the session (SPEC 4.4).
40
40
  */
41
41
  export declare function prepareSetForPlugin(file: OQSEFile, manifest: PluginManifest, options?: {
@@ -31,10 +31,17 @@ export interface SessionConfig {
31
31
  theme?: HostConfig['theme'];
32
32
  sessionId?: string;
33
33
  /**
34
- * Seed of the display order of options, pairs… (SPEC 4.4; default: the session id).
35
- * Pass the seed used for the relay bundle so all devices agree; `null` keeps the set's order.
34
+ * Secret seed of the display order of options, pairs… (SPEC 4.4; default: random). Pass the
35
+ * seed used for the relay bundle so all devices agree, and the same seed again to continue a
36
+ * saved game; `null` keeps the set's order. Never derive it from the session id or the PIN:
37
+ * a player could undo the shuffle.
36
38
  */
37
39
  shuffleSeed?: string | null;
40
+ /**
41
+ * Secret seed of the game's random numbers, given only to the authority (SPEC 4.2;
42
+ * default: 128 random bits). Pass one only to replay a game or in tests.
43
+ */
44
+ seed?: string;
38
45
  host?: {
39
46
  name: string;
40
47
  version: string;
@@ -84,14 +91,19 @@ export type SessionEvent = {
84
91
  }
85
92
  /**
86
93
  * A recorded answer. `record` is the updated learning progress (null for a generated item,
87
- * which is not repeated); `skills` / `tags` come from the item (for statistics).
94
+ * which is not repeated, and for an ungraded answer); `ungraded`: an answer that is not
95
+ * checked automatically (`open-ended`, SPEC 5.1) – kept for a teacher, repetition unchanged.
96
+ * `setId` + `answer.itemId` identify the item (OQSE: an item ID alone is not unique across
97
+ * sets); `skills` / `tags` come from the item (for statistics).
88
98
  */
89
99
  | {
90
100
  type: 'answer';
91
101
  playerId: string;
102
+ setId: string;
92
103
  record: ProgressRecord | null;
93
104
  answer: AnswerRecord;
94
105
  generated: boolean;
106
+ ungraded: boolean;
95
107
  skills: string[];
96
108
  tags: string[];
97
109
  }
@@ -142,6 +154,8 @@ export interface SessionInstance {
142
154
  }
143
155
  export declare class LocalSession {
144
156
  readonly id: string;
157
+ /** Secret seed of the game's random numbers (only the authority gets it). */
158
+ private readonly seed;
145
159
  readonly plugin: LoadedPlugin;
146
160
  readonly runtime: PluginRuntime;
147
161
  readonly mode: 'solo' | 'multiplayer';
package/dist/version.cjs CHANGED
@@ -1 +1 @@
1
- var e=`1.0.0-rc.6`;exports.HOST_SDK_VERSION=e;
1
+ var e=`1.0.0-rc.7`;exports.HOST_SDK_VERSION=e;
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region src/version.ts
2
- var e = "1.0.0-rc.6";
2
+ var e = "1.0.0-rc.7";
3
3
  //#endregion
4
4
  export { e as HOST_SDK_VERSION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memizy/host-sdk",
3
- "version": "1.0.0-rc.6",
3
+ "version": "1.0.0-rc.7",
4
4
  "description": "Run Memizy plugins: sandboxed iframes, protocol validation, sessions, storage hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -28,12 +28,12 @@
28
28
  "prepublishOnly": "npm run build"
29
29
  },
30
30
  "dependencies": {
31
- "@memizy/oqse": "^1.0.0-rc.6",
32
- "@memizy/protocol": "^1.0.0-rc.6",
31
+ "@memizy/oqse": "^1.0.0-rc.7",
32
+ "@memizy/protocol": "^1.0.0-rc.7",
33
33
  "penpal": "^8.0.1"
34
34
  },
35
35
  "devDependencies": {
36
- "@memizy/plugin-sdk": "^1.0.0-rc.6",
36
+ "@memizy/plugin-sdk": "^1.0.0-rc.7",
37
37
  "@types/node": "^25.6.0",
38
38
  "@types/ws": "^8",
39
39
  "jsdom": "^30.1.2",