@nano133/nanoterra 0.5.0 → 0.6.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 +20 -0
- package/README.md +133 -8
- package/ai/SPEC.md +25 -3
- 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 +13 -5
- package/dist/admin/handler.d.ts +1 -1
- package/dist/admin/handler.js +3 -3
- package/dist/admin/index.d.ts +2 -2
- package/dist/admin/index.js +2 -2
- package/dist/admin/memory.js +3 -1
- package/dist/admin/panel/gameCard.d.ts +1 -1
- package/dist/admin/panel/gameCard.js +56 -6
- package/dist/admin/panel/mount.js +1 -1
- package/dist/admin/panel/overview.d.ts +1 -1
- package/dist/admin/panel/overview.js +4 -2
- package/dist/admin/store.d.ts +2 -1
- package/dist/admin/switches.d.ts +23 -1
- package/dist/admin/switches.js +26 -5
- package/dist/friends/adapters/d1.d.ts +4 -0
- package/dist/friends/adapters/d1.js +131 -0
- package/dist/friends/adapters/firestore.d.ts +49 -0
- package/dist/friends/adapters/firestore.js +163 -0
- package/dist/friends/conformance.d.ts +11 -0
- package/dist/friends/conformance.js +229 -0
- package/dist/friends/handler.d.ts +31 -0
- package/dist/friends/handler.js +139 -0
- package/dist/friends/index.d.ts +8 -0
- package/dist/friends/index.js +12 -0
- package/dist/friends/memory.d.ts +9 -0
- package/dist/friends/memory.js +79 -0
- package/dist/friends/panel/index.d.ts +4 -0
- package/dist/friends/panel/index.js +10 -0
- package/dist/friends/panel/mount.d.ts +38 -0
- package/dist/friends/panel/mount.js +321 -0
- package/dist/friends/panel/window.d.ts +52 -0
- package/dist/friends/panel/window.js +72 -0
- package/dist/friends/rules.d.ts +92 -0
- package/dist/friends/rules.js +180 -0
- package/dist/friends/types.d.ts +58 -0
- package/dist/friends/types.js +24 -0
- package/package.json +11 -4
- package/src/admin/adapters/d1.ts +5 -4
- package/src/admin/adapters/firestore.ts +6 -5
- package/src/admin/conformance.ts +15 -6
- package/src/admin/handler.ts +3 -3
- package/src/admin/index.ts +3 -3
- package/src/admin/memory.ts +2 -1
- package/src/admin/panel/gameCard.ts +52 -6
- package/src/admin/panel/mount.ts +1 -1
- package/src/admin/panel/overview.ts +4 -3
- package/src/admin/panel/style.css +10 -1
- package/src/admin/store.ts +2 -2
- package/src/admin/switches.ts +35 -9
- package/src/friends/adapters/d1.sql +33 -0
- package/src/friends/adapters/d1.ts +146 -0
- package/src/friends/adapters/firestore.ts +197 -0
- package/src/friends/conformance.ts +260 -0
- package/src/friends/handler.ts +144 -0
- package/src/friends/index.ts +17 -0
- package/src/friends/memory.ts +83 -0
- package/src/friends/panel/index.ts +13 -0
- package/src/friends/panel/mount.ts +346 -0
- package/src/friends/panel/style.css +303 -0
- package/src/friends/panel/window.ts +79 -0
- package/src/friends/rules.ts +196 -0
- package/src/friends/types.ts +57 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The rules of the friends list, as pure functions: every adapter (memory, D1, Firestore) reads its rows, asks these what to write, and writes it
|
|
2
|
+
// in one step. So the rules live in one place, and the conformance suite (./conformance.ts) proves that an adapter keeps them.
|
|
3
|
+
//
|
|
4
|
+
// One pair of players has at most one edge. An edge is a request ("pending", from one of them), a friendship, or a block (by one or both).
|
|
5
|
+
// - A block hides the pair from the player who is blocked, both ways, and wins over a friendship or a request. The player who blocked sees a
|
|
6
|
+
// "blocked" row, so that they can unblock it.
|
|
7
|
+
// - A request that nobody answered goes after FRIEND_REQUEST_MS: it is not shown and does not count.
|
|
8
|
+
// - Every live edge counts toward FRIEND_LIMIT for both players.
|
|
9
|
+
import { FRIEND_INVITE_MS, FRIEND_LIMIT, FRIEND_LIVE_MS, FRIEND_REQUEST_MS, FRIEND_WHERE_MAX, FriendsError } from "./types.js";
|
|
10
|
+
/** The two ids of a pair in the order an adapter keeps them. */
|
|
11
|
+
export const pairOf = (x, y) => (x < y ? [x, y] : [y, x]);
|
|
12
|
+
export const otherOf = (rec, me) => (rec.a === me ? rec.b : rec.a);
|
|
13
|
+
export const hasPlayer = (rec, me) => rec.a === me || rec.b === me;
|
|
14
|
+
/** A request that nobody answered in time is gone; anything else is live. */
|
|
15
|
+
export const liveEdge = (rec, now) => rec.state !== "pending" || now - rec.at < FRIEND_REQUEST_MS;
|
|
16
|
+
/** A new opaque edge id: random, never made from a player id. */
|
|
17
|
+
export function newEdgeId() {
|
|
18
|
+
return globalThis.crypto.randomUUID();
|
|
19
|
+
}
|
|
20
|
+
/** An edge id as the browser may send one: letters, digits, "-" and "_", 8 to 64. Anything else is refused before a store is asked. */
|
|
21
|
+
export const EDGE_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
|
|
22
|
+
/** A tab's lease, as for an edge. */
|
|
23
|
+
export const LEASE_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
|
|
24
|
+
/**
|
|
25
|
+
* The game's `where` text as a store keeps it: control characters removed, spaces at the ends trimmed, cut to FRIEND_WHERE_MAX characters
|
|
26
|
+
* (whole characters, not halves of one). Null when nothing is left, or when it is not text.
|
|
27
|
+
*/
|
|
28
|
+
export function cleanWhere(where) {
|
|
29
|
+
if (typeof where !== "string")
|
|
30
|
+
return null;
|
|
31
|
+
// eslint-disable-next-line no-control-regex
|
|
32
|
+
const plain = where.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "").trim();
|
|
33
|
+
const cut = Array.from(plain).slice(0, FRIEND_WHERE_MAX).join("").trim();
|
|
34
|
+
return cut ? cut : null;
|
|
35
|
+
}
|
|
36
|
+
/** The state of an edge for one of its players, or null when they must not see it (a block by the other, an old request). */
|
|
37
|
+
export function stateFor(rec, me, now) {
|
|
38
|
+
if (!hasPlayer(rec, me) || !liveEdge(rec, now))
|
|
39
|
+
return null;
|
|
40
|
+
if (rec.state === "blocked")
|
|
41
|
+
return rec.blockedBy.includes(me) ? "blocked" : null;
|
|
42
|
+
if (rec.state === "friend")
|
|
43
|
+
return "friend";
|
|
44
|
+
return rec.from === me ? "outgoing" : "incoming";
|
|
45
|
+
}
|
|
46
|
+
const NOT_HERE = "This player is not on your list any more.";
|
|
47
|
+
/** The edge that `me` named, or a refusal: an edge of other players, or one they must not see, reads as gone (it says nothing about why). */
|
|
48
|
+
function mine(rec, me, now) {
|
|
49
|
+
if (!rec || stateFor(rec, me, now) === null)
|
|
50
|
+
throw new FriendsError(404, NOT_HERE);
|
|
51
|
+
return rec;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A request from `me` to `other`. `rec` is the pair's edge (or null); `counts` are the live edges of each, which the adapter reads only when a new
|
|
55
|
+
* edge would be made (it passes a function).
|
|
56
|
+
*/
|
|
57
|
+
export async function planRequest(me, other, rec, now, counts) {
|
|
58
|
+
if (me === other)
|
|
59
|
+
throw new FriendsError(400, "You cannot add yourself.");
|
|
60
|
+
const live = rec && liveEdge(rec, now) ? rec : null;
|
|
61
|
+
if (live?.state === "blocked")
|
|
62
|
+
throw new FriendsError(409, "This player is not available.");
|
|
63
|
+
if (live?.state === "friend")
|
|
64
|
+
return { none: true };
|
|
65
|
+
if (live?.state === "pending") {
|
|
66
|
+
if (live.from === me)
|
|
67
|
+
return { none: true };
|
|
68
|
+
// The other player already asked: this is an accept.
|
|
69
|
+
return { put: { ...live, from: me, state: "friend", at: now, invite: null, rev: live.rev + 1 } };
|
|
70
|
+
}
|
|
71
|
+
const c = await counts();
|
|
72
|
+
if (c.mine >= FRIEND_LIMIT)
|
|
73
|
+
throw new FriendsError(409, "Your friends list is full. Remove someone first.");
|
|
74
|
+
if (c.theirs >= FRIEND_LIMIT)
|
|
75
|
+
throw new FriendsError(409, "That player's friends list is full.");
|
|
76
|
+
const [a, b] = pairOf(me, other);
|
|
77
|
+
// An old request of the same pair is replaced (same row), so the pair still has one edge.
|
|
78
|
+
return { put: { edge: rec?.edge ?? newEdgeId(), a, b, from: me, state: "pending", blockedBy: [], at: now, invite: null, rev: (rec?.rev ?? 0) + 1 } };
|
|
79
|
+
}
|
|
80
|
+
export function planAccept(me, rec, now) {
|
|
81
|
+
const r = mine(rec, me, now);
|
|
82
|
+
const s = stateFor(r, me, now);
|
|
83
|
+
if (s === "friend")
|
|
84
|
+
return { none: true };
|
|
85
|
+
if (s !== "incoming")
|
|
86
|
+
throw new FriendsError(409, s === "outgoing" ? "Wait for the other player to accept." : "This request is no longer open.");
|
|
87
|
+
return { put: { ...r, from: me, state: "friend", at: now, invite: null, rev: r.rev + 1 } };
|
|
88
|
+
}
|
|
89
|
+
/** Declines a request to `me`, or takes back a request of `me`'s. */
|
|
90
|
+
export function planDecline(me, rec, now) {
|
|
91
|
+
const r = mine(rec, me, now);
|
|
92
|
+
const s = stateFor(r, me, now);
|
|
93
|
+
if (s !== "incoming" && s !== "outgoing")
|
|
94
|
+
throw new FriendsError(409, "This is not a request.");
|
|
95
|
+
return { drop: r };
|
|
96
|
+
}
|
|
97
|
+
/** Ends a friendship (or a request, either way). A block is ended by unblock, not by remove. */
|
|
98
|
+
export function planRemove(me, rec, now) {
|
|
99
|
+
const r = mine(rec, me, now);
|
|
100
|
+
if (stateFor(r, me, now) === "blocked")
|
|
101
|
+
throw new FriendsError(409, "Unblock this player first.");
|
|
102
|
+
return { drop: r };
|
|
103
|
+
}
|
|
104
|
+
export function planBlock(me, rec, now) {
|
|
105
|
+
const r = mine(rec, me, now);
|
|
106
|
+
if (r.blockedBy.includes(me))
|
|
107
|
+
return { none: true };
|
|
108
|
+
const blockedBy = r.state === "blocked" ? [...r.blockedBy, me] : [me];
|
|
109
|
+
return { put: { ...r, from: me, state: "blocked", blockedBy, at: now, invite: null, rev: r.rev + 1 } };
|
|
110
|
+
}
|
|
111
|
+
/** Takes `me`'s block away. With no block left the pair has no edge (they are not friends again: either may ask). */
|
|
112
|
+
export function planUnblock(me, rec, now) {
|
|
113
|
+
const r = mine(rec, me, now);
|
|
114
|
+
if (!r.blockedBy.includes(me))
|
|
115
|
+
throw new FriendsError(409, "This player is not blocked.");
|
|
116
|
+
const left = r.blockedBy.filter((p) => p !== me);
|
|
117
|
+
if (!left.length)
|
|
118
|
+
return { drop: r };
|
|
119
|
+
return { put: { ...r, blockedBy: left, at: now, rev: r.rev + 1 } };
|
|
120
|
+
}
|
|
121
|
+
export function planInvite(me, rec, where, now) {
|
|
122
|
+
const r = mine(rec, me, now);
|
|
123
|
+
if (stateFor(r, me, now) !== "friend")
|
|
124
|
+
throw new FriendsError(409, "You can invite only a friend.");
|
|
125
|
+
const place = cleanWhere(where);
|
|
126
|
+
if (!place)
|
|
127
|
+
throw new FriendsError(400, "Say where to meet.");
|
|
128
|
+
return { put: { ...r, invite: { from: me, where: place, at: now }, rev: r.rev + 1 } };
|
|
129
|
+
}
|
|
130
|
+
/** The lease and the place of a pulse, checked. `where` null means the tab is leaving (it clears the row only when the lease is the same). */
|
|
131
|
+
export function checkPulse(where, lease) {
|
|
132
|
+
if (typeof lease !== "string" || !LEASE_PATTERN.test(lease))
|
|
133
|
+
throw new FriendsError(400, "A pulse needs a lease of 8 to 64 letters or digits.");
|
|
134
|
+
if (where === null)
|
|
135
|
+
return { where: null, lease };
|
|
136
|
+
const place = cleanWhere(where);
|
|
137
|
+
if (!place)
|
|
138
|
+
throw new FriendsError(400, "Say where you are, or send null to leave.");
|
|
139
|
+
return { where: place, lease };
|
|
140
|
+
}
|
|
141
|
+
/** What a pulse does to the presence row `was`: the new row, a deletion, or nothing (an old tab that leaves after a newer one came). */
|
|
142
|
+
export function planPulse(player, where, lease, was, now) {
|
|
143
|
+
if (where !== null)
|
|
144
|
+
return { put: { player, where, lease, at: now } };
|
|
145
|
+
if (was && was.lease === lease)
|
|
146
|
+
return { drop: true };
|
|
147
|
+
return { none: true };
|
|
148
|
+
}
|
|
149
|
+
/** A presence row that still counts. */
|
|
150
|
+
export const presenceLive = (p, now) => Boolean(p) && now - p.at < FRIEND_LIVE_MS && now - p.at >= -FRIEND_LIVE_MS;
|
|
151
|
+
/** The order of the list: requests to answer first, then friends who are online, friends, requests sent, blocks; each by name. */
|
|
152
|
+
const RANK = { incoming: 0, friend: 1, outgoing: 3, blocked: 4 };
|
|
153
|
+
/**
|
|
154
|
+
* The browser's view of `me`'s edges. `presence` and `showOnline` are of the OTHER players (only friends' are read). `names` comes from the game's
|
|
155
|
+
* directory; a player it does not know reads "A player". No player id goes into a row.
|
|
156
|
+
*/
|
|
157
|
+
export function buildRows(me, recs, now, names, presence, showOnline) {
|
|
158
|
+
const rows = [];
|
|
159
|
+
for (const rec of recs) {
|
|
160
|
+
const state = stateFor(rec, me, now);
|
|
161
|
+
if (!state)
|
|
162
|
+
continue;
|
|
163
|
+
const other = otherOf(rec, me);
|
|
164
|
+
const p = state === "friend" && showOnline.get(other) !== false ? presence.get(other) : undefined;
|
|
165
|
+
const online = presenceLive(p, now);
|
|
166
|
+
const invite = state === "friend" && rec.invite && rec.invite.from !== me && now - rec.invite.at < FRIEND_INVITE_MS && now - rec.invite.at >= -FRIEND_INVITE_MS ? { where: rec.invite.where, at: rec.invite.at } : null;
|
|
167
|
+
const name = names.get(other);
|
|
168
|
+
rows.push({ edge: rec.edge, name: typeof name === "string" && name ? name : "A player", state, online, where: online ? p.where : null, invite, rank: state === "friend" && online ? 0.5 : RANK[state] });
|
|
169
|
+
}
|
|
170
|
+
rows.sort((x, y) => x.rank - y.rank || x.name.localeCompare(y.name) || (x.edge < y.edge ? -1 : 1));
|
|
171
|
+
return rows.map(({ rank: _rank, ...row }) => row);
|
|
172
|
+
}
|
|
173
|
+
/** The other players whose names a view needs: every edge `me` can see. */
|
|
174
|
+
export function shownOthers(me, recs, now) {
|
|
175
|
+
return [...new Set(recs.filter((r) => stateFor(r, me, now) !== null).map((r) => otherOf(r, me)))];
|
|
176
|
+
}
|
|
177
|
+
/** The friends of `me` (their presence is read for the view). */
|
|
178
|
+
export function friendsOf(me, recs, now) {
|
|
179
|
+
return recs.filter((r) => stateFor(r, me, now) === "friend").map((r) => otherOf(r, me));
|
|
180
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** A row of a player's list, as the browser gets it. `edge` is the store's opaque id for the pair (never a player id). */
|
|
2
|
+
export type FriendState = "friend" | "incoming" | "outgoing" | "blocked";
|
|
3
|
+
export type FriendRow = {
|
|
4
|
+
edge: string;
|
|
5
|
+
name: string;
|
|
6
|
+
state: FriendState;
|
|
7
|
+
online: boolean;
|
|
8
|
+
where: string | null;
|
|
9
|
+
invite: {
|
|
10
|
+
where: string;
|
|
11
|
+
at: number;
|
|
12
|
+
} | null;
|
|
13
|
+
};
|
|
14
|
+
export type FriendView = {
|
|
15
|
+
showOnline: boolean;
|
|
16
|
+
people: FriendRow[];
|
|
17
|
+
};
|
|
18
|
+
/** Live rows a player may have (friends, requests both ways, blocks), as the island. */
|
|
19
|
+
export declare const FRIEND_LIMIT = 100;
|
|
20
|
+
/** A pulse counts as online this long. */
|
|
21
|
+
export declare const FRIEND_LIVE_MS = 120000;
|
|
22
|
+
/** An invite shows this long. */
|
|
23
|
+
export declare const FRIEND_INVITE_MS = 600000;
|
|
24
|
+
/** An unanswered request goes after this. */
|
|
25
|
+
export declare const FRIEND_REQUEST_MS: number;
|
|
26
|
+
/** The longest `where` a store keeps (after trimming and removing control characters). */
|
|
27
|
+
export declare const FRIEND_WHERE_MAX = 64;
|
|
28
|
+
/** The game's players, which only the game knows: its sign-in id for a name, and the names to show. */
|
|
29
|
+
export interface PlayerDirectory {
|
|
30
|
+
byName(name: string): Promise<string | null>;
|
|
31
|
+
names(ids: string[]): Promise<Map<string, string>>;
|
|
32
|
+
}
|
|
33
|
+
/** The game's database for friends. An adapter writes ONLY friend rows, presence rows and the show-online choice. */
|
|
34
|
+
export interface FriendsStore {
|
|
35
|
+
view(player: string, directory: PlayerDirectory, now: number): Promise<FriendView>;
|
|
36
|
+
/** A request by the other player's id; an accept when the other already asked. Refuses: self, a full list, a block. */
|
|
37
|
+
request(player: string, other: string, now: number): Promise<void>;
|
|
38
|
+
accept(player: string, edge: string, now: number): Promise<void>;
|
|
39
|
+
decline(player: string, edge: string): Promise<void>;
|
|
40
|
+
remove(player: string, edge: string): Promise<void>;
|
|
41
|
+
block(player: string, edge: string, now: number): Promise<void>;
|
|
42
|
+
unblock(player: string, edge: string, now: number): Promise<void>;
|
|
43
|
+
/** Only between friends; `where` is the game's own text (a room, a level), at most 64 characters. */
|
|
44
|
+
invite(player: string, edge: string, where: string, now: number): Promise<void>;
|
|
45
|
+
/** Presence: here now (`where` or null), kept FRIEND_LIVE_MS; a `lease` per tab, so an old tab cannot clear a new one. */
|
|
46
|
+
pulse(player: string, where: string | null, lease: string, now: number): Promise<void>;
|
|
47
|
+
setShowOnline(player: string, on: boolean): Promise<void>;
|
|
48
|
+
/** A player's "delete my data": every row of theirs; returns how many. */
|
|
49
|
+
forget(player: string): Promise<number>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A refusal of a store or the handler: the status to answer and words a player can read. A store throws it for a rule (a block, a full list, an edge
|
|
53
|
+
* that is not theirs); anything else it throws is a fault, and the handler answers a plain 500.
|
|
54
|
+
*/
|
|
55
|
+
export declare class FriendsError extends Error {
|
|
56
|
+
status: 400 | 401 | 403 | 404 | 409 | 413 | 429;
|
|
57
|
+
constructor(status: 400 | 401 | 403 | 404 | 409 | 413 | 429, message: string);
|
|
58
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The friends list of a game's OWN players: the types, the limits and the store contract. The package never sees a Nanoterra account here: a
|
|
2
|
+
// player is the game's own id, and a name comes from the game's own directory. (Nanoterra's friends are another thing: the nano133 window, ./panel.)
|
|
3
|
+
/** Live rows a player may have (friends, requests both ways, blocks), as the island. */
|
|
4
|
+
export const FRIEND_LIMIT = 100;
|
|
5
|
+
/** A pulse counts as online this long. */
|
|
6
|
+
export const FRIEND_LIVE_MS = 120_000;
|
|
7
|
+
/** An invite shows this long. */
|
|
8
|
+
export const FRIEND_INVITE_MS = 600_000;
|
|
9
|
+
/** An unanswered request goes after this. */
|
|
10
|
+
export const FRIEND_REQUEST_MS = 30 * 86_400_000;
|
|
11
|
+
/** The longest `where` a store keeps (after trimming and removing control characters). */
|
|
12
|
+
export const FRIEND_WHERE_MAX = 64;
|
|
13
|
+
/**
|
|
14
|
+
* A refusal of a store or the handler: the status to answer and words a player can read. A store throws it for a rule (a block, a full list, an edge
|
|
15
|
+
* that is not theirs); anything else it throws is a fault, and the handler answers a plain 500.
|
|
16
|
+
*/
|
|
17
|
+
export class FriendsError extends Error {
|
|
18
|
+
status;
|
|
19
|
+
constructor(status, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.name = "FriendsError";
|
|
23
|
+
}
|
|
24
|
+
}
|
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) and the
|
|
3
|
+
"version": "0.6.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 and the friends list.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -18,7 +18,14 @@
|
|
|
18
18
|
"./admin/d1": { "types": "./dist/admin/adapters/d1.d.ts", "import": "./dist/admin/adapters/d1.js" },
|
|
19
19
|
"./admin/firestore": { "types": "./dist/admin/adapters/firestore.d.ts", "import": "./dist/admin/adapters/firestore.js" },
|
|
20
20
|
"./admin/panel": { "types": "./dist/admin/panel/index.d.ts", "import": "./dist/admin/panel/index.js" },
|
|
21
|
-
"./admin/style.css": "./src/admin/panel/style.css"
|
|
21
|
+
"./admin/style.css": "./src/admin/panel/style.css",
|
|
22
|
+
"./friends": { "types": "./dist/friends/index.d.ts", "import": "./dist/friends/index.js" },
|
|
23
|
+
"./friends/conformance": { "types": "./dist/friends/conformance.d.ts", "import": "./dist/friends/conformance.js" },
|
|
24
|
+
"./friends/d1": { "types": "./dist/friends/adapters/d1.d.ts", "import": "./dist/friends/adapters/d1.js" },
|
|
25
|
+
"./friends/d1.sql": "./src/friends/adapters/d1.sql",
|
|
26
|
+
"./friends/firestore": { "types": "./dist/friends/adapters/firestore.d.ts", "import": "./dist/friends/adapters/firestore.js" },
|
|
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"
|
|
22
29
|
},
|
|
23
30
|
"sideEffects": false,
|
|
24
31
|
"files": ["dist", "src", "README.md", "CHANGELOG.md", "LICENSE", "ai"],
|
|
@@ -46,5 +53,5 @@
|
|
|
46
53
|
},
|
|
47
54
|
"homepage": "https://nano133.com/build/nanoterra",
|
|
48
55
|
"license": "MIT",
|
|
49
|
-
"keywords": ["nano", "gltf", "glb", "model check", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics"]
|
|
56
|
+
"keywords": ["nano", "gltf", "glb", "model check", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics", "friends", "friends list"]
|
|
50
57
|
}
|
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 three settings (`setting:character`, `setting:signin`, `setting:friends` 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" } as const;
|
|
24
|
+
export const D1_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" } 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).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).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) };
|
|
39
|
+
return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin), friends: row(D1_KEYS.friends) };
|
|
40
40
|
},
|
|
41
41
|
|
|
42
42
|
async saveSettings(patch: GamePatch, now: number) {
|
|
@@ -45,6 +45,7 @@ export function createD1Store(db: D1Like, options: { logins?: boolean } = {}): A
|
|
|
45
45
|
const batch = [];
|
|
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
|
+
if (patch.friends) batch.push(write(D1_KEYS.friends, patch.friends));
|
|
48
49
|
if (batch.length) await db.batch(batch);
|
|
49
50
|
},
|
|
50
51
|
|
|
@@ -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 three 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", "rate" (value is JSON { usd, sources })
|
|
5
|
+
// meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "setting:friends", "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" } as const;
|
|
36
|
+
export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" } 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] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin)]);
|
|
53
|
-
return { character: character?.value, signin: signin?.value };
|
|
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 };
|
|
54
54
|
},
|
|
55
55
|
|
|
56
56
|
async saveSettings(patch: GamePatch, now: number) {
|
|
@@ -58,6 +58,7 @@ export function createFirestoreStore(db: FirestoreLike, collections: { meta?: st
|
|
|
58
58
|
let n = 0;
|
|
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
|
+
if (patch.friends) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.friends), { value: patch.friends, at: now }), n++);
|
|
61
62
|
if (n) await batch.commit();
|
|
62
63
|
},
|
|
63
64
|
|
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"|"warnings"/;
|
|
100
|
+
const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"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 three 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,16 @@ 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, `expected no settings, got ${JSON.stringify(s)}`);
|
|
361
|
+
check(s.character === undefined && s.signin === undefined && s.friends === undefined, `expected no settings, got ${JSON.stringify(s)}`);
|
|
362
|
+
});
|
|
363
|
+
|
|
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 }) => {
|
|
365
|
+
await store.saveSettings({ signin: "none", character: "own" }, NOW);
|
|
366
|
+
for (const [i, friends] of (["own", "off", "nanoterra"] as const).entries()) {
|
|
367
|
+
await store.saveSettings({ friends }, NOW + 1 + i);
|
|
368
|
+
const s = await store.readSettings();
|
|
369
|
+
check(s.friends === friends && s.signin === "none" && s.character === "own", `after friends ${friends}: ${JSON.stringify(s)}`);
|
|
370
|
+
}
|
|
362
371
|
});
|
|
363
372
|
|
|
364
373
|
add("a patch saves only what it names; the next patch keeps the rest", async ({ store }) => {
|
|
@@ -376,13 +385,13 @@ export function adminStoreConformance(make: () => Promise<StoreHarness>): Confor
|
|
|
376
385
|
add("an empty patch writes nothing and does not fail", async ({ store }) => {
|
|
377
386
|
await store.saveSettings({}, NOW);
|
|
378
387
|
const s = await store.readSettings();
|
|
379
|
-
check(s.character === undefined && s.signin === undefined, "an empty patch must save nothing");
|
|
388
|
+
check(s.character === undefined && s.signin === undefined && s.friends === undefined, "an empty patch must save nothing");
|
|
380
389
|
});
|
|
381
390
|
|
|
382
|
-
add("saving changes nothing but the
|
|
391
|
+
add("saving changes nothing but the settings: no order, no owned look, no other meta row", async (h) => {
|
|
383
392
|
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 } });
|
|
384
393
|
const before = await h.snapshot();
|
|
385
|
-
await h.store.saveSettings({ character: "own", signin: "none" }, NOW + 5);
|
|
394
|
+
await h.store.saveSettings({ character: "own", signin: "none", friends: "off" }, NOW + 5);
|
|
386
395
|
check((await h.snapshot()) === before, "the rows of the game changed after saving the settings");
|
|
387
396
|
});
|
|
388
397
|
|
package/src/admin/handler.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The admin handler: the routes of /admin on the Web `Request` and `Response` types, so it fits a Worker, Firebase Functions and Node.
|
|
2
2
|
//
|
|
3
3
|
// GET {base}/summary the numbers: status, orders, the looks that players own
|
|
4
|
-
// GET {base}/game the
|
|
5
|
-
// POST {base}/game a press: { character } or {
|
|
4
|
+
// GET {base}/game the game switches
|
|
5
|
+
// POST {base}/game a press: { character }, { signin } or { friends } (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 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 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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// adminAccess the guard: one pure function, who may open /admin
|
|
4
4
|
// createAdminHandler the routes, on Request and Response
|
|
5
|
-
// loadGameSettings the
|
|
5
|
+
// loadGameSettings the game switches for the game's own routes
|
|
6
6
|
// createMemoryStore a store in memory, for tests
|
|
7
7
|
// (the conformance suite is its own path: "@nano133/nanoterra/admin/conformance"; the adapters are "/admin/d1" and "/admin/firestore")
|
|
8
8
|
|
|
@@ -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, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
|
|
21
|
-
export type { Character, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.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";
|
package/src/admin/memory.ts
CHANGED
|
@@ -35,11 +35,12 @@ 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") };
|
|
38
|
+
return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin"), friends: store.rows.get("setting:friends") };
|
|
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
|
+
if (patch.friends) store.rows.set("setting:friends", patch.friends);
|
|
43
44
|
store.writes++;
|
|
44
45
|
},
|
|
45
46
|
async summary(now: number): Promise<StoreSummary> {
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra"
|
|
2
|
-
// (POST {api}/game, behind the same admin guard as every admin call) and
|
|
3
|
-
// nothing. The words are in ../switches.ts. The card changes ONLY these
|
|
1
|
+
// The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra", and one three-way
|
|
2
|
+
// choice, "Friends list: Nanoterra / The game's own / Off". A press saves at once (POST {api}/game, behind the same admin guard as every admin call) and
|
|
3
|
+
// the game reads the setting at run time, so the creator pushes and deploys nothing. The words are in ../switches.ts. The card changes ONLY these three
|
|
4
|
+
// settings: never a look, a price or an order.
|
|
4
5
|
|
|
5
|
-
import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf, type GameState, type SwitchId } from "../switches.js";
|
|
6
|
+
import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf, type Friends, type GameState, type SwitchId } from "../switches.js";
|
|
6
7
|
import { ApiError, type Api } from "./api.js";
|
|
7
8
|
import { h } from "./dom.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* The card for the settings `initial`. It keeps its own state: a press shows "Saving…", then "Saved." or the server's words. `defaultsFile` is where the game's defaults are
|
|
11
|
-
* written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the
|
|
12
|
+
* written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the switches too).
|
|
12
13
|
*/
|
|
13
14
|
export function gameCard(api: Api, initial: GameState, defaultsFile = "the game's own settings", onChange?: (state: GameState) => void): HTMLElement {
|
|
14
15
|
let state = initial;
|
|
@@ -16,7 +17,11 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
|
|
|
16
17
|
/** Under the character switch: it works only through sign-in, so it says so, and says what a player gets while sign-in is Off. */
|
|
17
18
|
const needs = h("p", { class: "fine switch-needs" });
|
|
18
19
|
const card = h("section", { class: "admin-card game-card", "aria-labelledby": "game-title" }, h("div", { class: "card-head" }, h("h2", { id: "game-title" }, "Game")));
|
|
19
|
-
card.append(h("p", { class: "fine card-intro" }, `
|
|
20
|
+
card.append(h("p", { class: "fine card-intro" }, `Three choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
|
|
21
|
+
/** The friends choice: a radio group of three keys (arrow keys move between them, as a radio group does). */
|
|
22
|
+
const friendsKeys = new Map<Friends, HTMLButtonElement>();
|
|
23
|
+
const friendsNote = h("p", { class: "switch-note", id: "switch-friends-note" });
|
|
24
|
+
const friendsStatus = h("span", { class: "fine", role: "status", "aria-live": "polite" });
|
|
20
25
|
|
|
21
26
|
const draw = () => {
|
|
22
27
|
for (const [id, row] of rows) {
|
|
@@ -28,6 +33,29 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
|
|
|
28
33
|
}
|
|
29
34
|
needs.hidden = !stateOf("character", state);
|
|
30
35
|
needs.textContent = signinOn(state.signin) ? CHARACTER_NEEDS_SIGNIN : CHARACTER_SIGNIN_OFF;
|
|
36
|
+
for (const [id, key] of friendsKeys) {
|
|
37
|
+
const on = state.friends === id;
|
|
38
|
+
key.setAttribute("aria-checked", on ? "true" : "false");
|
|
39
|
+
key.tabIndex = on ? 0 : -1;
|
|
40
|
+
key.classList.toggle("is-on", on);
|
|
41
|
+
}
|
|
42
|
+
friendsNote.textContent = FRIENDS_CHOICES[state.friends]?.note ?? "";
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const choose = async (id: Friends) => {
|
|
46
|
+
if (state.friends === id) return;
|
|
47
|
+
for (const key of friendsKeys.values()) key.disabled = true;
|
|
48
|
+
friendsStatus.textContent = "Saving…";
|
|
49
|
+
try {
|
|
50
|
+
state = await api.post<GameState>("/game", { friends: id });
|
|
51
|
+
friendsStatus.textContent = "Saved.";
|
|
52
|
+
draw();
|
|
53
|
+
onChange?.(state);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
friendsStatus.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Nothing was changed.";
|
|
56
|
+
} finally {
|
|
57
|
+
for (const key of friendsKeys.values()) key.disabled = false;
|
|
58
|
+
}
|
|
31
59
|
};
|
|
32
60
|
|
|
33
61
|
const press = async (id: SwitchId) => {
|
|
@@ -62,6 +90,24 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
|
|
|
62
90
|
// The sign-in word is part of the listing file: Nanoterra's review decides what Nanoterra shows. This page cannot see that review.
|
|
63
91
|
if (id === "signin") card.append(h("p", { class: "fine switch-review" }, REVIEW_NOTE, " ", h("a", { href: REVIEW_LINK, target: "_blank", rel: "noopener noreferrer" }, "Open the launch page")));
|
|
64
92
|
}
|
|
93
|
+
|
|
94
|
+
const order = Object.keys(FRIENDS_CHOICES) as Friends[];
|
|
95
|
+
const group = h("div", { class: "seg friends-choice", role: "radiogroup", "aria-labelledby": "switch-friends-label", "aria-describedby": "switch-friends-note" });
|
|
96
|
+
for (const id of order) {
|
|
97
|
+
const key = h("button", { class: "seg-btn", type: "button", role: "radio", "aria-checked": "false", "data-friends": id }, FRIENDS_CHOICES[id].name);
|
|
98
|
+
key.addEventListener("click", () => void choose(id));
|
|
99
|
+
key.addEventListener("keydown", (e) => {
|
|
100
|
+
const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
|
|
101
|
+
if (!step) return;
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
const next = order[(order.indexOf(id) + step + order.length) % order.length];
|
|
104
|
+
friendsKeys.get(next)!.focus();
|
|
105
|
+
void choose(next);
|
|
106
|
+
});
|
|
107
|
+
friendsKeys.set(id, key);
|
|
108
|
+
group.append(key);
|
|
109
|
+
}
|
|
110
|
+
card.append(h("div", { class: "switch-row friends-row" }, h("div", { class: "switch-head" }, h("span", { class: "switch-title", id: "switch-friends-label" }, FRIENDS_TITLE), group), friendsNote, friendsStatus));
|
|
65
111
|
draw();
|
|
66
112
|
return card;
|
|
67
113
|
}
|
package/src/admin/panel/mount.ts
CHANGED
|
@@ -69,7 +69,7 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
|
|
|
69
69
|
let gameState: GameState | null = null;
|
|
70
70
|
let dash: Dashboard | null = null;
|
|
71
71
|
|
|
72
|
-
/** The card, made once. A fault in reading the
|
|
72
|
+
/** The card, made once. A fault in reading the game switches must not hide the orders: the card is left out and the rest still shows. */
|
|
73
73
|
async function gameSection(): Promise<HTMLElement | null> {
|
|
74
74
|
if (game) return game;
|
|
75
75
|
try {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import type { Look } from "../../looks/look.js";
|
|
5
5
|
import { priceText } from "../../looks/ladder.js";
|
|
6
6
|
import type { AdminSummary } from "../store.js";
|
|
7
|
-
import { SWITCHES, stateOf, type GameState } from "../switches.js";
|
|
7
|
+
import { FRIENDS_CHOICES, FRIENDS_TITLE, SWITCHES, stateOf, type GameState } from "../switches.js";
|
|
8
8
|
import { dollars, h, plural } from "./dom.js";
|
|
9
9
|
import { icon } from "./icons.js";
|
|
10
10
|
import { orderRow, type OrderFilter } from "./orders.js";
|
|
@@ -29,7 +29,7 @@ export function bestSellers(s: Pick<AdminSummary, "orders" | "players">, looks:
|
|
|
29
29
|
export type OverviewView = {
|
|
30
30
|
el: HTMLElement;
|
|
31
31
|
update(s: AdminSummary): void;
|
|
32
|
-
/** The
|
|
32
|
+
/** The game switches as they are now (null: they could not be read). */
|
|
33
33
|
setGame(state: GameState | null): void;
|
|
34
34
|
};
|
|
35
35
|
|
|
@@ -51,9 +51,10 @@ export function overviewView(looks: readonly Look[], colourOf: (lookId: string)
|
|
|
51
51
|
const on = game ? stateOf(id, game) : null;
|
|
52
52
|
return h("li", {}, h("span", {}, SWITCHES[id].title), on === null ? h("span", { class: "pill" }, "Unknown") : h("span", { class: `pill ${on ? "pill-on" : "pill-off"}` }, on ? "On" : "Off"));
|
|
53
53
|
});
|
|
54
|
+
if (game) rows.push(h("li", {}, h("span", {}, FRIENDS_TITLE), h("span", { class: `pill ${game.friends === "off" ? "pill-off" : "pill-on"}` }, FRIENDS_CHOICES[game.friends]?.name ?? "Unknown")));
|
|
54
55
|
gameBox.replaceChildren(
|
|
55
56
|
h("div", { class: "card-head" }, h("h2", { id: "n133-glance-game" }, "Game switches"), linkKey("Change", () => go("settings"))),
|
|
56
|
-
game ? h("ul", { class: "glance-list" }, ...rows) : h("p", { class: "fine" }, "The
|
|
57
|
+
game ? h("ul", { class: "glance-list" }, ...rows) : h("p", { class: "fine" }, "The game switches could not be read. Reload the page to try again."),
|
|
57
58
|
);
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -1679,7 +1679,16 @@
|
|
|
1679
1679
|
font-size: 12px;
|
|
1680
1680
|
}
|
|
1681
1681
|
|
|
1682
|
-
/* ---- The "Game" card: two switches (gameCard.ts). Each is a button of at least 44 px ---- */
|
|
1682
|
+
/* ---- The "Game" card: two switches and the friends choice (gameCard.ts). Each is a button of at least 44 px ---- */
|
|
1683
|
+
|
|
1684
|
+
/* The friends choice: three keys in a row that wraps on a narrow phone, each 44 px tall. */
|
|
1685
|
+
.n133-admin .game-card .friends-row .switch-head {
|
|
1686
|
+
flex-wrap: wrap;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
.n133-admin .game-card .friends-choice .seg-btn {
|
|
1690
|
+
min-height: 44px;
|
|
1691
|
+
}
|
|
1683
1692
|
|
|
1684
1693
|
.n133-admin .game-card .switch-row {
|
|
1685
1694
|
display: grid;
|