@nano133/nanoterra 0.7.0 → 0.8.0
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 +50 -0
- package/README.md +206 -10
- package/ai/SPEC.md +51 -2
- package/dist/admin/adapters/d1.d.ts +1 -0
- package/dist/admin/adapters/d1.js +6 -4
- package/dist/admin/adapters/firestore.d.ts +1 -0
- package/dist/admin/adapters/firestore.js +7 -5
- package/dist/admin/conformance.d.ts +1 -1
- package/dist/admin/conformance.js +12 -4
- package/dist/admin/handler.d.ts +1 -1
- package/dist/admin/handler.js +2 -2
- package/dist/admin/index.d.ts +2 -2
- package/dist/admin/index.js +1 -1
- package/dist/admin/memory.js +3 -1
- package/dist/admin/panel/gameCard.js +13 -10
- package/dist/admin/panel/icons.d.ts +17 -0
- package/dist/admin/panel/icons.js +3 -0
- package/dist/admin/panel/overview.js +1 -1
- package/dist/admin/store.d.ts +2 -1
- package/dist/admin/switches.d.ts +13 -3
- package/dist/admin/switches.js +25 -6
- package/dist/character/create.js +6 -2
- package/dist/character/layer.d.ts +14 -0
- package/dist/character/layer.js +10 -0
- package/dist/emotes/clip.d.ts +79 -0
- package/dist/emotes/clip.js +165 -0
- package/dist/emotes/clips.d.ts +3 -0
- package/dist/emotes/clips.js +2 -0
- package/dist/emotes/events.d.ts +22 -0
- package/dist/emotes/events.js +19 -0
- package/dist/emotes/icons.d.ts +1 -0
- package/dist/emotes/icons.js +6 -0
- package/dist/emotes/index.d.ts +8 -0
- package/dist/emotes/index.js +11 -0
- package/dist/emotes/list.d.ts +123 -0
- package/dist/emotes/list.js +23 -0
- package/dist/emotes/options.d.ts +16 -0
- package/dist/emotes/options.js +34 -0
- package/dist/emotes/panel/index.d.ts +3 -0
- package/dist/emotes/panel/index.js +9 -0
- package/dist/emotes/panel/mount.d.ts +56 -0
- package/dist/emotes/panel/mount.js +360 -0
- package/dist/emotes/player.d.ts +37 -0
- package/dist/emotes/player.js +101 -0
- package/dist/emotes/wheel.d.ts +42 -0
- package/dist/emotes/wheel.js +110 -0
- package/dist/friends/panel/mount.d.ts +3 -3
- package/dist/friends/panel/mount.js +46 -12
- package/dist/options/common.d.ts +88 -0
- package/dist/options/common.js +59 -0
- package/dist/options/core.d.ts +130 -0
- package/dist/options/core.js +420 -0
- package/dist/options/index.d.ts +7 -0
- package/dist/options/index.js +24 -0
- package/dist/options/panel/index.d.ts +2 -0
- package/dist/options/panel/index.js +10 -0
- package/dist/options/panel/mount.d.ts +30 -0
- package/dist/options/panel/mount.js +378 -0
- package/dist/options/reflect.d.ts +5 -0
- package/dist/options/reflect.js +27 -0
- package/package.json +12 -4
- package/src/admin/adapters/d1.ts +5 -4
- package/src/admin/adapters/firestore.ts +6 -5
- package/src/admin/conformance.ts +14 -5
- package/src/admin/handler.ts +2 -2
- package/src/admin/index.ts +2 -2
- package/src/admin/memory.ts +2 -1
- package/src/admin/panel/gameCard.ts +13 -9
- package/src/admin/panel/icons.ts +3 -0
- package/src/admin/panel/overview.ts +1 -1
- package/src/admin/store.ts +2 -2
- package/src/admin/switches.ts +29 -12
- package/src/character/create.ts +6 -2
- package/src/character/layer.ts +21 -0
- package/src/emotes/clip.ts +204 -0
- package/src/emotes/clips.ts +7 -0
- package/src/emotes/events.ts +28 -0
- package/src/emotes/icons.ts +7 -0
- package/src/emotes/icons.webp +0 -0
- package/src/emotes/index.ts +16 -0
- package/src/emotes/list.ts +33 -0
- package/src/emotes/options.ts +43 -0
- package/src/emotes/panel/index.ts +11 -0
- package/src/emotes/panel/mount.ts +390 -0
- package/src/emotes/panel/style.css +415 -0
- package/src/emotes/player.ts +131 -0
- package/src/emotes/wheel.ts +131 -0
- package/src/friends/panel/mount.ts +47 -13
- package/src/friends/panel/style.css +26 -0
- package/src/options/common.ts +65 -0
- package/src/options/core.ts +465 -0
- package/src/options/index.ts +41 -0
- package/src/options/panel/index.ts +12 -0
- package/src/options/panel/mount.ts +418 -0
- package/src/options/panel/style.css +528 -0
- package/src/options/reflect.ts +28 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// The Options key and panel of a game: `mountOptions(element, store, options)`. Plain DOM, no framework, no Three.js. It draws the store's defs (../core.ts)
|
|
2
|
+
// in two tabs, Basic and Advanced, each in its groups, and writes every change to the store at once: there is no Save key. A change from elsewhere (the game
|
|
3
|
+
// sets a value, another view resets one) shows at once too, and a section a module adds later (store.add) is drawn when it comes.
|
|
4
|
+
//
|
|
5
|
+
// toggle a switch: the whole row is the key (role="switch").
|
|
6
|
+
// choice a row of keys (role="radiogroup", the arrow keys move) for at most 4 short choices, else a drop-down; a def's `display` chooses.
|
|
7
|
+
// range a slider with its value and unit beside the label. A slider that a finger or the mouse holds is never drawn again under it.
|
|
8
|
+
// key a key binding: the key's name; pressed, it waits for the next key (Escape or Tab stops waiting). A key another binding has is swapped (the store does it).
|
|
9
|
+
//
|
|
10
|
+
// Rows are drawn once and changed in place; the panel never scrolls back to the top on a change (Echo Island's draft, 2026-11-01).
|
|
11
|
+
// "Reset to defaults" asks in the panel (a question and a key for each answer), never in a browser dialog. Escape answers the question first, then closes.
|
|
12
|
+
// The panel is phone first: a sheet from the bottom on a phone held upright, from the right on a wide or sideways screen; every key is 44 px or more.
|
|
13
|
+
// `key: false` draws no key, for a game that opens the panel from its own pause menu (`open()`).
|
|
14
|
+
import { h } from "../../admin/panel/dom.js";
|
|
15
|
+
import { icon } from "../../admin/panel/icons.js";
|
|
16
|
+
import { keyLabel } from "../core.js";
|
|
17
|
+
export const TAB_WORDS = { basic: "Basic", advanced: "Advanced" };
|
|
18
|
+
const TABS = ["basic", "advanced"];
|
|
19
|
+
export const RESET_QUESTION = "Put every option back to its default?";
|
|
20
|
+
/** On a key binding while it waits for its key. */
|
|
21
|
+
export const PRESS_A_KEY = "Press a key";
|
|
22
|
+
/** "80%", "3 s", "50". */
|
|
23
|
+
export const rangeText = (def, v) => `${v}${def.unit ?? ""}`;
|
|
24
|
+
/** Buttons for at most 4 choices of at most 10 characters, else a list; the def's `display` wins. */
|
|
25
|
+
export const choiceDisplay = (def) => def.display ?? (def.choices.length <= 4 && def.choices.every((c) => c.label.length <= 10) ? "buttons" : "list");
|
|
26
|
+
let mounts = 0;
|
|
27
|
+
export function mountOptions(root, store, options = {}) {
|
|
28
|
+
const n = ++mounts;
|
|
29
|
+
const id = (part) => `n133o-${n}-${part}`;
|
|
30
|
+
const title = options.title ?? "Options";
|
|
31
|
+
let isOpen = false;
|
|
32
|
+
let destroyed = false;
|
|
33
|
+
let tab = "basic";
|
|
34
|
+
let returnTo = null;
|
|
35
|
+
const cleanup = [];
|
|
36
|
+
const key = options.key === false
|
|
37
|
+
? null
|
|
38
|
+
: h("button", { class: "n133o-key", type: "button", "aria-expanded": "false", "aria-controls": id("panel"), "aria-haspopup": "dialog" }, icon("gear", 20), h("span", { class: "n133o-key-word" }, title));
|
|
39
|
+
const closeKey = h("button", { class: "n133o-close", type: "button", "aria-label": `Close ${title.toLowerCase()}` }, icon("close", 20));
|
|
40
|
+
const tabKeys = Object.fromEntries(TABS.map((t) => [t, h("button", { class: "n133o-tab", type: "button", role: "tab", id: id(`tab-${t}`), "aria-controls": id(`pane-${t}`), "data-tab": t }, TAB_WORDS[t])]));
|
|
41
|
+
const tabList = h("div", { class: "n133o-tabs", role: "tablist", "aria-label": title }, tabKeys.basic, tabKeys.advanced);
|
|
42
|
+
const panes = Object.fromEntries(TABS.map((t) => [t, h("div", { class: "n133o-pane", role: "tabpanel", id: id(`pane-${t}`), "aria-labelledby": id(`tab-${t}`), "data-tab": t })]));
|
|
43
|
+
const body = h("div", { class: "n133o-body" }, panes.basic, panes.advanced);
|
|
44
|
+
const question = h("p", { class: "n133o-ask-q", id: id("ask") });
|
|
45
|
+
const yesKey = h("button", { class: "n133o-btn n133o-primary", type: "button" });
|
|
46
|
+
const noKey = h("button", { class: "n133o-btn", type: "button" }, "Cancel");
|
|
47
|
+
const askBox = h("div", { class: "n133o-ask", role: "alertdialog", "aria-labelledby": id("ask") }, question, h("div", { class: "n133o-ask-keys" }, yesKey, noKey));
|
|
48
|
+
askBox.hidden = true;
|
|
49
|
+
const resetKey = h("button", { class: "n133o-btn n133o-reset", type: "button" }, icon("refresh", 18), "Reset to defaults");
|
|
50
|
+
const foot = h("footer", { class: "n133o-foot" }, askBox, resetKey);
|
|
51
|
+
const panel = h("section", { class: "n133o-panel", id: id("panel"), role: "dialog", "aria-modal": "true", "aria-labelledby": id("title"), "data-open": "false" }, h("header", { class: "n133o-head" }, h("h2", { class: "n133o-title", id: id("title") }, title), closeKey), tabList, body, foot);
|
|
52
|
+
const scrim = h("div", { class: "n133o-scrim", "data-open": "false", "aria-hidden": "true" });
|
|
53
|
+
const wrap = h("div", { class: "n133-options" }, key, scrim, panel);
|
|
54
|
+
root.append(wrap);
|
|
55
|
+
// ---- the rows ----
|
|
56
|
+
/** Each drawn key's way to show a new value. */
|
|
57
|
+
let shows = new Map();
|
|
58
|
+
const textOf = (def, labelTag = "span", forId) => {
|
|
59
|
+
const label = h(labelTag, { class: "n133o-label", id: id(`l-${def.key}`), for: forId }, def.label);
|
|
60
|
+
const help = def.help ? h("span", { class: "n133o-help", id: id(`h-${def.key}`) }, def.help) : null;
|
|
61
|
+
return { label, help, describedBy: help ? id(`h-${def.key}`) : undefined };
|
|
62
|
+
};
|
|
63
|
+
function toggleRow(def) {
|
|
64
|
+
const t = textOf(def);
|
|
65
|
+
const row = h("button", { class: "n133o-row n133o-toggle", type: "button", role: "switch", "aria-labelledby": id(`l-${def.key}`), "aria-describedby": t.describedBy, "data-key": def.key }, h("span", { class: "n133o-text" }, t.label, t.help), h("span", { class: "n133o-switch", "aria-hidden": "true" }, h("span", { class: "n133o-knob" })));
|
|
66
|
+
row.addEventListener("click", () => store.set(def.key, !store.get(def.key)));
|
|
67
|
+
shows.set(def.key, (v) => row.setAttribute("aria-checked", String(v === true)));
|
|
68
|
+
return row;
|
|
69
|
+
}
|
|
70
|
+
function choiceRow(def) {
|
|
71
|
+
if (choiceDisplay(def) === "list") {
|
|
72
|
+
const selectId = id(`c-${def.key}`);
|
|
73
|
+
const t = textOf(def, "label", selectId);
|
|
74
|
+
const byIndex = def.choices.map((c) => c.value);
|
|
75
|
+
const select = h("select", { class: "n133o-select", id: selectId, "aria-describedby": t.describedBy }, ...def.choices.map((c, i) => h("option", { value: String(i) }, c.label)));
|
|
76
|
+
select.addEventListener("change", () => store.set(def.key, byIndex[Number(select.value)]));
|
|
77
|
+
shows.set(def.key, (v) => (select.value = String(Math.max(0, byIndex.indexOf(v)))));
|
|
78
|
+
return h("div", { class: "n133o-row n133o-choice", "data-key": def.key }, h("span", { class: "n133o-text" }, t.label, t.help), select);
|
|
79
|
+
}
|
|
80
|
+
const t = textOf(def);
|
|
81
|
+
const keys = def.choices.map((c) => h("button", { class: "n133o-seg-key", type: "button", role: "radio" }, c.label));
|
|
82
|
+
const group = h("div", { class: "n133o-seg", role: "radiogroup", "aria-labelledby": id(`l-${def.key}`), "aria-describedby": t.describedBy }, ...keys);
|
|
83
|
+
keys.forEach((k, i) => k.addEventListener("click", () => store.set(def.key, def.choices[i].value)));
|
|
84
|
+
group.addEventListener("keydown", (e) => {
|
|
85
|
+
const at = keys.indexOf(document.activeElement);
|
|
86
|
+
const to = moveKey(e, at, keys.length);
|
|
87
|
+
if (to === null)
|
|
88
|
+
return;
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
store.set(def.key, def.choices[to].value);
|
|
91
|
+
keys[to].focus();
|
|
92
|
+
});
|
|
93
|
+
shows.set(def.key, (v) => keys.forEach((k, i) => {
|
|
94
|
+
const on = def.choices[i].value === v;
|
|
95
|
+
k.setAttribute("aria-checked", String(on));
|
|
96
|
+
k.tabIndex = on ? 0 : -1;
|
|
97
|
+
}));
|
|
98
|
+
return h("div", { class: "n133o-row n133o-choice", "data-key": def.key }, h("span", { class: "n133o-text" }, t.label, t.help), group);
|
|
99
|
+
}
|
|
100
|
+
function rangeRow(def) {
|
|
101
|
+
const inputId = id(`r-${def.key}`);
|
|
102
|
+
const t = textOf(def, "label", inputId);
|
|
103
|
+
const value = h("output", { class: "n133o-value", for: inputId, "aria-hidden": "true" });
|
|
104
|
+
const input = h("input", { class: "n133o-range-input", type: "range", id: inputId, min: def.min, max: def.max, step: def.step, "aria-describedby": t.describedBy });
|
|
105
|
+
input.addEventListener("input", () => store.set(def.key, Number(input.value)));
|
|
106
|
+
input.addEventListener("change", () => options.onRangeDone?.(def.key, Number(input.value)));
|
|
107
|
+
input.addEventListener("pointerdown", () => {
|
|
108
|
+
held = true;
|
|
109
|
+
const letGo = () => {
|
|
110
|
+
held = false;
|
|
111
|
+
window.removeEventListener("pointerup", letGo);
|
|
112
|
+
window.removeEventListener("pointercancel", letGo);
|
|
113
|
+
if (drawLater)
|
|
114
|
+
draw();
|
|
115
|
+
};
|
|
116
|
+
window.addEventListener("pointerup", letGo);
|
|
117
|
+
window.addEventListener("pointercancel", letGo);
|
|
118
|
+
});
|
|
119
|
+
shows.set(def.key, (v) => {
|
|
120
|
+
const num = v;
|
|
121
|
+
if (Number(input.value) !== num)
|
|
122
|
+
input.value = String(num);
|
|
123
|
+
value.textContent = rangeText(def, num);
|
|
124
|
+
input.setAttribute("aria-valuetext", rangeText(def, num));
|
|
125
|
+
// The filled part of the track, for the stylesheet.
|
|
126
|
+
input.style.setProperty("--n133o-fill", `${((num - def.min) / (def.max - def.min)) * 100}%`);
|
|
127
|
+
});
|
|
128
|
+
return h("div", { class: "n133o-row n133o-range", "data-key": def.key }, h("span", { class: "n133o-text n133o-range-text" }, t.label, value, t.help), input);
|
|
129
|
+
}
|
|
130
|
+
/** The binding that waits for its key, and the call that stops it. */
|
|
131
|
+
let waiting = null;
|
|
132
|
+
const stopWaiting = () => waiting?.stop();
|
|
133
|
+
function keyRow(def) {
|
|
134
|
+
const t = textOf(def);
|
|
135
|
+
const cap = h("button", { class: "n133o-keycap", type: "button", "aria-describedby": t.describedBy });
|
|
136
|
+
const show = (v) => {
|
|
137
|
+
if (waiting?.key === def.key)
|
|
138
|
+
return;
|
|
139
|
+
cap.textContent = keyLabel(String(v));
|
|
140
|
+
cap.setAttribute("aria-label", `${def.label}: ${keyLabel(String(v))}. Press to change`);
|
|
141
|
+
cap.setAttribute("aria-pressed", "false");
|
|
142
|
+
};
|
|
143
|
+
cap.addEventListener("click", () => {
|
|
144
|
+
if (waiting?.key === def.key)
|
|
145
|
+
return stopWaiting();
|
|
146
|
+
stopWaiting();
|
|
147
|
+
const onKey = (e) => {
|
|
148
|
+
// Tab stops waiting and moves the focus as usual.
|
|
149
|
+
if (e.code === "Tab")
|
|
150
|
+
return stop();
|
|
151
|
+
// The capture phase of the window: the game and the panel's Escape never hear this key.
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
e.stopImmediatePropagation();
|
|
154
|
+
stop();
|
|
155
|
+
if (e.code && e.code !== "Escape")
|
|
156
|
+
store.set(def.key, e.code);
|
|
157
|
+
};
|
|
158
|
+
const stop = () => {
|
|
159
|
+
window.removeEventListener("keydown", onKey, true);
|
|
160
|
+
waiting = null;
|
|
161
|
+
show(store.get(def.key));
|
|
162
|
+
};
|
|
163
|
+
waiting = { key: def.key, stop };
|
|
164
|
+
window.addEventListener("keydown", onKey, true);
|
|
165
|
+
cap.textContent = PRESS_A_KEY;
|
|
166
|
+
cap.setAttribute("aria-label", `${def.label}: ${PRESS_A_KEY.toLowerCase()}. Escape keeps the old key`);
|
|
167
|
+
cap.setAttribute("aria-pressed", "true");
|
|
168
|
+
});
|
|
169
|
+
shows.set(def.key, show);
|
|
170
|
+
return h("div", { class: "n133o-row n133o-key-row", "data-key": def.key }, h("span", { class: "n133o-text" }, t.label, t.help), cap);
|
|
171
|
+
}
|
|
172
|
+
const rowFor = (def) => (def.kind === "toggle" ? toggleRow(def) : def.kind === "choice" ? choiceRow(def) : def.kind === "key" ? keyRow(def) : rangeRow(def));
|
|
173
|
+
/** A finger or the mouse holds a slider: a new draw waits until it lets go. */
|
|
174
|
+
let held = false;
|
|
175
|
+
let drawLater = false;
|
|
176
|
+
/** Draws both tabs from the store's defs (the ones for this device), in their groups. Keeps the tab and, when it can, the focus. */
|
|
177
|
+
function draw() {
|
|
178
|
+
if (held) {
|
|
179
|
+
drawLater = true;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
drawLater = false;
|
|
183
|
+
stopWaiting();
|
|
184
|
+
const scroll = body.scrollTop;
|
|
185
|
+
const focusKey = document.activeElement?.closest?.("[data-key]")?.getAttribute("data-key") ?? null;
|
|
186
|
+
shows = new Map();
|
|
187
|
+
const shown = store.defs().filter((d) => !d.devices || d.devices.includes(store.device));
|
|
188
|
+
const has = { basic: false, advanced: false };
|
|
189
|
+
for (const t of TABS) {
|
|
190
|
+
const defs = shown.filter((d) => d.tab === t);
|
|
191
|
+
has[t] = defs.length > 0;
|
|
192
|
+
const loose = defs.filter((d) => !d.group);
|
|
193
|
+
const groups = [...new Set(defs.filter((d) => d.group).map((d) => d.group))];
|
|
194
|
+
const parts = [];
|
|
195
|
+
if (loose.length)
|
|
196
|
+
parts.push(h("div", { class: "n133o-rows" }, ...loose.map(rowFor)));
|
|
197
|
+
for (const g of groups) {
|
|
198
|
+
const gid = id(`g-${t}-${groups.indexOf(g)}`);
|
|
199
|
+
parts.push(h("section", { class: "n133o-group", "aria-labelledby": gid }, h("h3", { class: "n133o-group-title", id: gid }, g), h("div", { class: "n133o-rows" }, ...defs.filter((d) => d.group === g).map(rowFor))));
|
|
200
|
+
}
|
|
201
|
+
panes[t].replaceChildren(...parts);
|
|
202
|
+
}
|
|
203
|
+
// Both tabs when both have rows; one tab alone needs no tab bar.
|
|
204
|
+
const both = has.basic && has.advanced;
|
|
205
|
+
tabList.hidden = !both;
|
|
206
|
+
if (!has[tab])
|
|
207
|
+
tab = has.basic ? "basic" : "advanced";
|
|
208
|
+
if (!has.basic && !has.advanced)
|
|
209
|
+
panes.basic.replaceChildren(h("p", { class: "n133o-empty" }, "No options yet."));
|
|
210
|
+
for (const t of TABS) {
|
|
211
|
+
if (both)
|
|
212
|
+
panes[t].setAttribute("role", "tabpanel");
|
|
213
|
+
else
|
|
214
|
+
panes[t].removeAttribute("role");
|
|
215
|
+
}
|
|
216
|
+
selectTab(tab, false);
|
|
217
|
+
body.scrollTop = scroll;
|
|
218
|
+
for (const [k, show] of shows)
|
|
219
|
+
show(store.get(k));
|
|
220
|
+
resetKey.disabled = store.isDefault();
|
|
221
|
+
// A key is letters, digits, "_", "-" and dots (core.ts checks it), so it goes into the selector as it is.
|
|
222
|
+
const row = focusKey ? panel.querySelector(`[data-key="${focusKey}"]`) : null;
|
|
223
|
+
if (row)
|
|
224
|
+
(row.tagName === "BUTTON" ? row : row.querySelector('button[tabindex="0"], select, input'))?.focus();
|
|
225
|
+
}
|
|
226
|
+
function selectTab(t, focus) {
|
|
227
|
+
const moved = t !== tab;
|
|
228
|
+
tab = t;
|
|
229
|
+
for (const x of TABS) {
|
|
230
|
+
const on = x === t;
|
|
231
|
+
tabKeys[x].setAttribute("aria-selected", String(on));
|
|
232
|
+
tabKeys[x].tabIndex = on ? 0 : -1;
|
|
233
|
+
panes[x].hidden = !on;
|
|
234
|
+
}
|
|
235
|
+
if (moved)
|
|
236
|
+
body.scrollTop = 0;
|
|
237
|
+
if (focus)
|
|
238
|
+
tabKeys[t].focus();
|
|
239
|
+
}
|
|
240
|
+
for (const t of TABS)
|
|
241
|
+
tabKeys[t].addEventListener("click", () => selectTab(t, true));
|
|
242
|
+
tabList.addEventListener("keydown", (e) => {
|
|
243
|
+
const visible = TABS.filter((t) => !tabKeys[t].hidden);
|
|
244
|
+
const to = moveKey(e, visible.indexOf(tab), visible.length);
|
|
245
|
+
if (to === null)
|
|
246
|
+
return;
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
selectTab(visible[to], true);
|
|
249
|
+
});
|
|
250
|
+
cleanup.push(store.subscribe((k, v) => {
|
|
251
|
+
shows.get(k)?.(v);
|
|
252
|
+
resetKey.disabled = store.isDefault();
|
|
253
|
+
}));
|
|
254
|
+
cleanup.push(store.subscribeDefs(draw));
|
|
255
|
+
draw();
|
|
256
|
+
// ---- open, close, ask ----
|
|
257
|
+
const open = () => {
|
|
258
|
+
if (destroyed || isOpen)
|
|
259
|
+
return;
|
|
260
|
+
isOpen = true;
|
|
261
|
+
returnTo = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null;
|
|
262
|
+
panel.dataset.open = scrim.dataset.open = "true";
|
|
263
|
+
key?.setAttribute("aria-expanded", "true");
|
|
264
|
+
options.onOpen?.();
|
|
265
|
+
(tabList.hidden ? closeKey : tabKeys[tab]).focus();
|
|
266
|
+
};
|
|
267
|
+
const close = () => {
|
|
268
|
+
if (destroyed || !isOpen)
|
|
269
|
+
return;
|
|
270
|
+
answer?.(false);
|
|
271
|
+
stopWaiting();
|
|
272
|
+
isOpen = false;
|
|
273
|
+
panel.dataset.open = scrim.dataset.open = "false";
|
|
274
|
+
key?.setAttribute("aria-expanded", "false");
|
|
275
|
+
options.onClose?.();
|
|
276
|
+
const back = key ?? (returnTo?.isConnected ? returnTo : null);
|
|
277
|
+
back?.focus();
|
|
278
|
+
};
|
|
279
|
+
key?.addEventListener("click", () => (isOpen ? close() : open()));
|
|
280
|
+
closeKey.addEventListener("click", close);
|
|
281
|
+
scrim.addEventListener("click", close);
|
|
282
|
+
/** The question in the panel: one at a time (a new one answers the old with no), and closing the panel answers no. */
|
|
283
|
+
let answer = null;
|
|
284
|
+
const askInPanel = (q, yesWord) => new Promise((resolve) => {
|
|
285
|
+
answer?.(false);
|
|
286
|
+
question.textContent = q;
|
|
287
|
+
yesKey.textContent = yesWord;
|
|
288
|
+
askBox.hidden = false;
|
|
289
|
+
resetKey.hidden = true;
|
|
290
|
+
answer = (yes) => {
|
|
291
|
+
answer = null;
|
|
292
|
+
askBox.hidden = true;
|
|
293
|
+
resetKey.hidden = false;
|
|
294
|
+
resolve(yes);
|
|
295
|
+
if (isOpen && !destroyed)
|
|
296
|
+
resetKey.focus();
|
|
297
|
+
};
|
|
298
|
+
open();
|
|
299
|
+
yesKey.focus();
|
|
300
|
+
});
|
|
301
|
+
yesKey.addEventListener("click", () => answer?.(true));
|
|
302
|
+
noKey.addEventListener("click", () => answer?.(false));
|
|
303
|
+
const ask = (q, yesWord) => (options.confirm ? Promise.resolve(options.confirm(q)) : askInPanel(q, yesWord));
|
|
304
|
+
cleanup.push(() => answer?.(false), stopWaiting);
|
|
305
|
+
resetKey.addEventListener("click", () => {
|
|
306
|
+
void ask(RESET_QUESTION, "Reset").then((yes) => {
|
|
307
|
+
if (yes && !destroyed)
|
|
308
|
+
store.reset();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
const onKey = (e) => {
|
|
312
|
+
if (!isOpen)
|
|
313
|
+
return;
|
|
314
|
+
if (e.key === "Escape") {
|
|
315
|
+
// Escape answers an open question first, then closes the panel.
|
|
316
|
+
if (answer)
|
|
317
|
+
answer(false);
|
|
318
|
+
else
|
|
319
|
+
close();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (e.key === "Tab") {
|
|
323
|
+
// The panel holds the focus while it is open (it covers the game).
|
|
324
|
+
const all = Array.from(panel.querySelectorAll("button, select, input, [tabindex]")).filter((el) => el.tabIndex >= 0 && !el.disabled && el.closest("[hidden]") === null);
|
|
325
|
+
if (!all.length)
|
|
326
|
+
return;
|
|
327
|
+
const first = all[0];
|
|
328
|
+
const last = all[all.length - 1];
|
|
329
|
+
if (e.shiftKey && (document.activeElement === first || !panel.contains(document.activeElement))) {
|
|
330
|
+
e.preventDefault();
|
|
331
|
+
last.focus();
|
|
332
|
+
}
|
|
333
|
+
else if (!e.shiftKey && (document.activeElement === last || !panel.contains(document.activeElement))) {
|
|
334
|
+
e.preventDefault();
|
|
335
|
+
first.focus();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
document.addEventListener("keydown", onKey);
|
|
340
|
+
cleanup.push(() => document.removeEventListener("keydown", onKey));
|
|
341
|
+
return {
|
|
342
|
+
open,
|
|
343
|
+
close,
|
|
344
|
+
get isOpen() {
|
|
345
|
+
return isOpen;
|
|
346
|
+
},
|
|
347
|
+
destroy() {
|
|
348
|
+
if (destroyed)
|
|
349
|
+
return;
|
|
350
|
+
if (isOpen)
|
|
351
|
+
close();
|
|
352
|
+
destroyed = true;
|
|
353
|
+
for (const f of cleanup)
|
|
354
|
+
f();
|
|
355
|
+
wrap.remove();
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/** The arrow keys, Home and End in a row of `count` (wrapping): the new place, or null for another key. */
|
|
360
|
+
function moveKey(e, at, count) {
|
|
361
|
+
if (count < 1)
|
|
362
|
+
return null;
|
|
363
|
+
const from = at < 0 ? 0 : at;
|
|
364
|
+
switch (e.key) {
|
|
365
|
+
case "ArrowRight":
|
|
366
|
+
case "ArrowDown":
|
|
367
|
+
return (from + 1) % count;
|
|
368
|
+
case "ArrowLeft":
|
|
369
|
+
case "ArrowUp":
|
|
370
|
+
return (from - 1 + count) % count;
|
|
371
|
+
case "Home":
|
|
372
|
+
return 0;
|
|
373
|
+
case "End":
|
|
374
|
+
return count - 1;
|
|
375
|
+
default:
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OptionsStore } from "./core.js";
|
|
2
|
+
/** The attribute that holds a key's value: "reducedMotion" gives "data-n133-opt-reduced-motion". */
|
|
3
|
+
export declare const optionAttribute: (key: string) => string;
|
|
4
|
+
/** Keeps each key's value on `target` (default: the page's <html>) as it changes. Returns the call that stops and takes the attributes off. */
|
|
5
|
+
export declare function reflectOptions(store: OptionsStore, keys: readonly string[], target?: Element | null): () => void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Settings the page's CSS needs too: `reflectOptions(store, ["reducedMotion"])` keeps `data-n133-opt-reduced-motion="true" | "false"` on the page's <html>,
|
|
2
|
+
// so a stylesheet can calm every animation of the game's own HUD:
|
|
3
|
+
//
|
|
4
|
+
// html[data-n133-opt-reduced-motion="true"] .my-hud * { animation: none !important; transition: none !important; }
|
|
5
|
+
//
|
|
6
|
+
// Echo Island set a class on its root for its "Less motion" (2026-11-01); this is the kit's one way. The attribute's name is "data-n133-opt-" and the key in
|
|
7
|
+
// kebab case ("emotes.show" gives data-n133-opt-emotes-show); its value is the setting's value as text.
|
|
8
|
+
/** The attribute that holds a key's value: "reducedMotion" gives "data-n133-opt-reduced-motion". */
|
|
9
|
+
export const optionAttribute = (key) => `data-n133-opt-${key.replace(/\./g, "-").replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
10
|
+
/** Keeps each key's value on `target` (default: the page's <html>) as it changes. Returns the call that stops and takes the attributes off. */
|
|
11
|
+
export function reflectOptions(store, keys, target = globalThis.document?.documentElement ?? null) {
|
|
12
|
+
if (!target)
|
|
13
|
+
return () => { };
|
|
14
|
+
for (const k of keys) {
|
|
15
|
+
store.get(k); // an unknown key throws here, at once
|
|
16
|
+
target.setAttribute(optionAttribute(k), String(store.get(k)));
|
|
17
|
+
}
|
|
18
|
+
const off = store.subscribe((k, v) => {
|
|
19
|
+
if (keys.includes(k))
|
|
20
|
+
target.setAttribute(optionAttribute(k), String(v));
|
|
21
|
+
});
|
|
22
|
+
return () => {
|
|
23
|
+
off();
|
|
24
|
+
for (const k of keys)
|
|
25
|
+
target.removeAttribute(optionAttribute(k));
|
|
26
|
+
};
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano133/nanoterra",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton), the looks ladder, the admin
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton), the looks ladder, the admin, the friends list and an options menu.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -25,7 +25,15 @@
|
|
|
25
25
|
"./friends/d1.sql": "./src/friends/adapters/d1.sql",
|
|
26
26
|
"./friends/firestore": { "types": "./dist/friends/adapters/firestore.d.ts", "import": "./dist/friends/adapters/firestore.js" },
|
|
27
27
|
"./friends/panel": { "types": "./dist/friends/panel/index.d.ts", "import": "./dist/friends/panel/index.js" },
|
|
28
|
-
"./friends/style.css": "./src/friends/panel/style.css"
|
|
28
|
+
"./friends/style.css": "./src/friends/panel/style.css",
|
|
29
|
+
"./options": { "types": "./dist/options/index.d.ts", "import": "./dist/options/index.js" },
|
|
30
|
+
"./options/panel": { "types": "./dist/options/panel/index.d.ts", "import": "./dist/options/panel/index.js" },
|
|
31
|
+
"./options/style.css": "./src/options/panel/style.css",
|
|
32
|
+
"./emotes": { "types": "./dist/emotes/index.d.ts", "import": "./dist/emotes/index.js" },
|
|
33
|
+
"./emotes/player": { "types": "./dist/emotes/player.d.ts", "import": "./dist/emotes/player.js" },
|
|
34
|
+
"./emotes/wheel": { "types": "./dist/emotes/panel/index.d.ts", "import": "./dist/emotes/panel/index.js" },
|
|
35
|
+
"./emotes/style.css": "./src/emotes/panel/style.css",
|
|
36
|
+
"./emotes/icons.webp": "./src/emotes/icons.webp"
|
|
29
37
|
},
|
|
30
38
|
"sideEffects": false,
|
|
31
39
|
"files": ["dist", "src", "README.md", "CHANGELOG.md", "LICENSE", "ai"],
|
|
@@ -53,5 +61,5 @@
|
|
|
53
61
|
},
|
|
54
62
|
"homepage": "https://nano133.com/build/nanoterra",
|
|
55
63
|
"license": "MIT",
|
|
56
|
-
"keywords": ["nano", "gltf", "glb", "model check", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics", "friends", "friends list"]
|
|
64
|
+
"keywords": ["nano", "gltf", "glb", "model check", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics", "friends", "friends list", "options", "settings", "options menu", "emotes", "emote wheel"]
|
|
57
65
|
}
|
package/src/admin/adapters/d1.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The admin store for a game on Cloudflare D1, with the tables of the game starter (migrations/0001_init.sql): `meta`, `orders` and `owned`.
|
|
2
|
-
// It writes ONLY the
|
|
2
|
+
// It writes ONLY the four settings (`setting:character`, `setting:signin`, `setting:friends`, `setting:emotes` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
|
|
3
3
|
// (`logins: true`): then the one other write is `DELETE FROM sessions WHERE scope = 'admin'` ("sign out everywhere").
|
|
4
4
|
//
|
|
5
5
|
// import { createD1Store } from "@nano133/nanoterra/admin/d1";
|
|
@@ -21,7 +21,7 @@ export interface D1Like {
|
|
|
21
21
|
batch(statements: D1StatementLike[]): Promise<unknown[]>;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export const D1_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" } as const;
|
|
24
|
+
export const D1_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends", emotes: "setting:emotes" } as const;
|
|
25
25
|
|
|
26
26
|
type Row = { id: string; player: string; look_id: string; usd: string; amount: string; status: string; created_at: number; expires_at: number; paid_at: number | null; payer: string | null; hash: string | null };
|
|
27
27
|
|
|
@@ -34,9 +34,9 @@ type Row = { id: string; player: string; look_id: string; usd: string; amount: s
|
|
|
34
34
|
export function createD1Store(db: D1Like, options: { logins?: boolean } = {}): AdminStore {
|
|
35
35
|
const store: AdminStore = {
|
|
36
36
|
async readSettings() {
|
|
37
|
-
const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?, ?)").bind(D1_KEYS.character, D1_KEYS.signin, D1_KEYS.friends).all<{ key: string; value: string }>()).results;
|
|
37
|
+
const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?, ?, ?)").bind(D1_KEYS.character, D1_KEYS.signin, D1_KEYS.friends, D1_KEYS.emotes).all<{ key: string; value: string }>()).results;
|
|
38
38
|
const row = (key: string) => rows.find((r) => r.key === key)?.value;
|
|
39
|
-
return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin), friends: row(D1_KEYS.friends) };
|
|
39
|
+
return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin), friends: row(D1_KEYS.friends), emotes: row(D1_KEYS.emotes) };
|
|
40
40
|
},
|
|
41
41
|
|
|
42
42
|
async saveSettings(patch: GamePatch, now: number) {
|
|
@@ -46,6 +46,7 @@ export function createD1Store(db: D1Like, options: { logins?: boolean } = {}): A
|
|
|
46
46
|
if (patch.character) batch.push(write(D1_KEYS.character, patch.character));
|
|
47
47
|
if (patch.signin) batch.push(write(D1_KEYS.signin, patch.signin));
|
|
48
48
|
if (patch.friends) batch.push(write(D1_KEYS.friends, patch.friends));
|
|
49
|
+
if (patch.emotes) batch.push(write(D1_KEYS.emotes, patch.emotes));
|
|
49
50
|
if (batch.length) await db.batch(batch);
|
|
50
51
|
},
|
|
51
52
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The admin store for a game on Firestore (the Admin SDK). The package has no Firebase dependency: the adapter uses the small interface below, which the
|
|
2
|
-
// Admin SDK's `Firestore` fits. It writes ONLY the
|
|
2
|
+
// Admin SDK's `Firestore` fits. It writes ONLY the four settings; everything else it does is a read.
|
|
3
3
|
//
|
|
4
4
|
// The data model (the names a game must have; only the three collection names can be changed):
|
|
5
|
-
// meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "setting:friends", "rate" (value is JSON { usd, sources })
|
|
5
|
+
// meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "setting:friends", "setting:emotes", "rate" (value is JSON { usd, sources })
|
|
6
6
|
// orders/{id} { player, lookId, usd, amount, status, createdAt, expiresAt, paidAt, payer, hash } `amount` is the raw amount as text
|
|
7
7
|
// owned/{player__lookId} { player, lookId, orderId, grantedAt }
|
|
8
8
|
//
|
|
@@ -33,7 +33,7 @@ export interface FirestoreLike {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export const FIRESTORE_SCAN_LIMIT = 5000;
|
|
36
|
-
export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" } as const;
|
|
36
|
+
export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends", emotes: "setting:emotes" } as const;
|
|
37
37
|
|
|
38
38
|
const text = (v: unknown, fallback = ""): string => (typeof v === "string" ? v : fallback);
|
|
39
39
|
const num = (v: unknown, fallback = 0): number => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
|
|
@@ -49,8 +49,8 @@ export function createFirestoreStore(db: FirestoreLike, collections: { meta?: st
|
|
|
49
49
|
};
|
|
50
50
|
return {
|
|
51
51
|
async readSettings() {
|
|
52
|
-
const [character, signin, friends] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin), valueOf(FIRESTORE_KEYS.friends)]);
|
|
53
|
-
return { character: character?.value, signin: signin?.value, friends: friends?.value };
|
|
52
|
+
const [character, signin, friends, emotes] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin), valueOf(FIRESTORE_KEYS.friends), valueOf(FIRESTORE_KEYS.emotes)]);
|
|
53
|
+
return { character: character?.value, signin: signin?.value, friends: friends?.value, emotes: emotes?.value };
|
|
54
54
|
},
|
|
55
55
|
|
|
56
56
|
async saveSettings(patch: GamePatch, now: number) {
|
|
@@ -59,6 +59,7 @@ export function createFirestoreStore(db: FirestoreLike, collections: { meta?: st
|
|
|
59
59
|
if (patch.character) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.character), { value: patch.character, at: now }), n++);
|
|
60
60
|
if (patch.signin) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.signin), { value: patch.signin, at: now }), n++);
|
|
61
61
|
if (patch.friends) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.friends), { value: patch.friends, at: now }), n++);
|
|
62
|
+
if (patch.emotes) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.emotes), { value: patch.emotes, at: now }), n++);
|
|
62
63
|
if (n) await batch.commit();
|
|
63
64
|
},
|
|
64
65
|
|
package/src/admin/conformance.ts
CHANGED
|
@@ -97,7 +97,7 @@ async function read(res: Response): Promise<{ status: number; text: string; json
|
|
|
97
97
|
const REFUSAL_KEYS = new Set(["error", "reason", "signedInAs"]);
|
|
98
98
|
|
|
99
99
|
/** Words of the game's data that a refusal or an answer to a visitor must never hold. */
|
|
100
|
-
const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"warnings"/;
|
|
100
|
+
const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"emotes"|"warnings"/;
|
|
101
101
|
|
|
102
102
|
export function adminConformance(host: ConformanceHost): ConformanceCase[] {
|
|
103
103
|
const cases: ConformanceCase[] = [];
|
|
@@ -332,7 +332,7 @@ export function adminConformance(host: ConformanceHost): ConformanceCase[] {
|
|
|
332
332
|
export type StoreHarness = {
|
|
333
333
|
store: AdminStore;
|
|
334
334
|
seed(data: { orders?: OrderRow[]; owned?: { player: string; lookId: string; at: number }[]; rate?: { value: string; at: number } }): Promise<void>;
|
|
335
|
-
/** Every row that the store could change by mistake, as one text: the orders, the owned looks and the meta rows that are NOT the
|
|
335
|
+
/** Every row that the store could change by mistake, as one text: the orders, the owned looks and the meta rows that are NOT the four settings. Same rows, same text. */
|
|
336
336
|
snapshot(): Promise<string>;
|
|
337
337
|
/** Only for a store with `recentLogins` and `signOutEverywhere`: put admin sign-ins in the game's database (the store only reads them). */
|
|
338
338
|
seedLogins?(rows: { at: number; country: string | null; browser: string }[]): Promise<void>;
|
|
@@ -358,7 +358,7 @@ export function adminStoreConformance(make: () => Promise<StoreHarness>): Confor
|
|
|
358
358
|
|
|
359
359
|
add("a new store has no settings of its own", async ({ store }) => {
|
|
360
360
|
const s = await store.readSettings();
|
|
361
|
-
check(s.character === undefined && s.signin === undefined && s.friends === undefined, `expected no settings, got ${JSON.stringify(s)}`);
|
|
361
|
+
check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined, `expected no settings, got ${JSON.stringify(s)}`);
|
|
362
362
|
});
|
|
363
363
|
|
|
364
364
|
add("the friends list is kept as its own setting: each of its three words, and a patch of it leaves the other two", async ({ store }) => {
|
|
@@ -370,6 +370,15 @@ export function adminStoreConformance(make: () => Promise<StoreHarness>): Confor
|
|
|
370
370
|
}
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
+
add("the emotes switch is kept as its own setting: off and on, and a patch of it leaves the other three", async ({ store }) => {
|
|
374
|
+
await store.saveSettings({ signin: "none", character: "own", friends: "off" }, NOW);
|
|
375
|
+
for (const [i, emotes] of (["off", "on"] as const).entries()) {
|
|
376
|
+
await store.saveSettings({ emotes }, NOW + 1 + i);
|
|
377
|
+
const s = await store.readSettings();
|
|
378
|
+
check(s.emotes === emotes && s.signin === "none" && s.character === "own" && s.friends === "off", `after emotes ${emotes}: ${JSON.stringify(s)}`);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
373
382
|
add("a patch saves only what it names; the next patch keeps the rest", async ({ store }) => {
|
|
374
383
|
await store.saveSettings({ signin: "none" }, NOW);
|
|
375
384
|
let s = await store.readSettings();
|
|
@@ -385,13 +394,13 @@ export function adminStoreConformance(make: () => Promise<StoreHarness>): Confor
|
|
|
385
394
|
add("an empty patch writes nothing and does not fail", async ({ store }) => {
|
|
386
395
|
await store.saveSettings({}, NOW);
|
|
387
396
|
const s = await store.readSettings();
|
|
388
|
-
check(s.character === undefined && s.signin === undefined && s.friends === undefined, "an empty patch must save nothing");
|
|
397
|
+
check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined, "an empty patch must save nothing");
|
|
389
398
|
});
|
|
390
399
|
|
|
391
400
|
add("saving changes nothing but the settings: no order, no owned look, no other meta row", async (h) => {
|
|
392
401
|
await h.seed({ orders: [order("1", {}), order("2", { status: "open" })], owned: [{ player: A, lookId: "mint-cap", at: NOW }], rate: { value: JSON.stringify({ usd: 1.2, sources: ["x"] }), at: NOW } });
|
|
393
402
|
const before = await h.snapshot();
|
|
394
|
-
await h.store.saveSettings({ character: "own", signin: "none", friends: "off" }, NOW + 5);
|
|
403
|
+
await h.store.saveSettings({ character: "own", signin: "none", friends: "off", emotes: "off" }, NOW + 5);
|
|
395
404
|
check((await h.snapshot()) === before, "the rows of the game changed after saving the settings");
|
|
396
405
|
});
|
|
397
406
|
|
package/src/admin/handler.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// GET {base}/summary the numbers: status, orders, the looks that players own
|
|
4
4
|
// GET {base}/game the game switches
|
|
5
|
-
// POST {base}/game a press: { character }, { signin } or {
|
|
5
|
+
// POST {base}/game a press: { character }, { signin }, { friends } or { emotes } (only the known words)
|
|
6
6
|
// POST {base}/check the payment check, when the game gives one
|
|
7
7
|
//
|
|
8
8
|
// EVERY request under the base goes through `adminAccess` first, before the route is even looked up: a visitor who is not the owner gets 401 or 403
|
|
@@ -190,7 +190,7 @@ export function createAdminHandler(options: AdminHandlerOptions): AdminHandler {
|
|
|
190
190
|
return { routes, basePath: options.basePath ?? "/api/admin", handle };
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
/** The settings as the game's own routes need them (the listing file, the character, the friends list): the stored words, or the game's defaults. A fault in the store gives the defaults. */
|
|
193
|
+
/** The settings as the game's own routes need them (the listing file, the character, the friends list, the emotes): the stored words, or the game's defaults. A fault in the store gives the defaults. */
|
|
194
194
|
export async function loadGameSettings(store: Pick<AdminStore, "readSettings">, defaults: GameSettings): Promise<GameState> {
|
|
195
195
|
try {
|
|
196
196
|
return mergeSettings(await store.readSettings(), defaults);
|
package/src/admin/index.ts
CHANGED
|
@@ -17,5 +17,5 @@ export type { AdminLogin, AdminOrder, AdminPlayer, AdminStatus, AdminStore, Admi
|
|
|
17
17
|
export { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers, statusOf, summarize } from "./summary.js";
|
|
18
18
|
export type { OrderRow } from "./summary.js";
|
|
19
19
|
export { RAW_PER_XNO, xnoText } from "./amount.js";
|
|
20
|
-
export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, DEFAULT_FRIENDS, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isFriends, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
|
|
21
|
-
export type { Character, Friends, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
|
|
20
|
+
export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, DEFAULT_EMOTES, DEFAULT_FRIENDS, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isEmotes, isFriends, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
|
|
21
|
+
export type { Character, Emotes, Friends, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
|
package/src/admin/memory.ts
CHANGED
|
@@ -35,12 +35,13 @@ export function createMemoryStore(seed: { orders?: OrderRow[]; owned?: { player:
|
|
|
35
35
|
pendingAdminRequests: 0,
|
|
36
36
|
pendingGameRequests: 0,
|
|
37
37
|
async readSettings() {
|
|
38
|
-
return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin"), friends: store.rows.get("setting:friends") };
|
|
38
|
+
return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin"), friends: store.rows.get("setting:friends"), emotes: store.rows.get("setting:emotes") };
|
|
39
39
|
},
|
|
40
40
|
async saveSettings(patch: GamePatch) {
|
|
41
41
|
if (patch.character) store.rows.set("setting:character", patch.character);
|
|
42
42
|
if (patch.signin) store.rows.set("setting:signin", patch.signin);
|
|
43
43
|
if (patch.friends) store.rows.set("setting:friends", patch.friends);
|
|
44
|
+
if (patch.emotes) store.rows.set("setting:emotes", patch.emotes);
|
|
44
45
|
store.writes++;
|
|
45
46
|
},
|
|
46
47
|
async summary(now: number): Promise<StoreSummary> {
|