Compare commits

..
4 Commits
21 changed files with 297 additions and 18 deletions

No files matched your search

+7 -2
View File
@@ -163,6 +163,8 @@ Build new UI from these instead of coordinate math. Each file's header documents
dialog's Druid exist only while it is open or closing, and a close ends within 1 s; a
break is repaired and logged as `ERROR:MM: input invariant broken: ...` (check.py fails
a log with `ERROR:MM`).
- `screens/game/grant.lua`: the quick grant dialog for admins (`state.admin`: header "+"
button, G key): item and amount pickers, then `admin-grant`.
- `panel.lua`: the `.frame` panel with a body group and the entrance (`p:enter`).
- `card.lua`: image column + text column laid out with flex (below; measured text, `title`,
`text`, `para`, `block` (fixed height, or measured: `block(fn)` with fn returning its
@@ -307,6 +309,8 @@ the markup colors). Fields refuse `<` and `&`. Measuring (`ui.text_width`,
components.
- `mmwindow/`: our native extension, `mmwindow.set_fullscreen(on)` / `is_fullscreen()`
(the engine's GLFW window to its monitor and back; Defold has no runtime fullscreen).
On desktop it also makes the window's mouse buttons and keys sticky (GLFW), so a click
or key press shorter than a frame isn't lost (the engine polls their states once a frame).
Changing it rebuilds the engine on the build server. On the web it builds as stubs; the
browser's Fullscreen API is used instead (`modules/screen.lua`).
- `mmterrain/`: native extension, the moon generator in C (`src/terrain.c`, a port of
@@ -472,8 +476,9 @@ The game it starts saves its files under `<out>/data` (XDG_DATA_HOME, or
build shows the engine's profiler.
Those go through the test hook, which calls the screen's input function directly, so
they work even when the engine sends the game no input. Real input through the X server
(xdotool on the driver's Xvfb; key and button held 80 ms, since the engine polls once a
frame): `os_click(id | prefix)`, `os_move(x, y)`, `os_key("Escape")`, `os_point(id)`.
(xdotool on the driver's Xvfb, its own display from `Xvfb -displayfd`: `xvfb-run -a` raced
when two runs started together; key and button held 80 ms like a hand; the engine polls
once a frame, and mmwindow's sticky keys and buttons keep a press shorter than a frame): `os_click(id | prefix)`, `os_move(x, y)`, `os_key("Escape")`, `os_point(id)`.
`g.engine_inputs` counts the engine's own `on_input` calls, `g.builds` the rebuilds.
- Run: `scripts/drive.py --api <server> [--fixture | --fixture-file fx.json] [--size
2560x1600] [--config mm.x=y] <scenario> [args]`. It starts the engine from
+10 -6
View File
@@ -29,7 +29,6 @@ local theme = require("modules.theme")
local ui = require("modules.ui")
local anim = require("modules.anim")
local test = require("modules.test")
local helper = require("druid.helper")
local druid_button = require("druid.base.button")
local S = theme.size
@@ -144,23 +143,28 @@ function M.release()
carry.pressed = nil
end
-- After a rebuild, with the mouse still down at (x, y) (screen) on a button of the build
-- before: press the new button(s) with its id, so the release clicks it (Druid checks the pointer then).
-- before: press the new button with its id under the pointer, so the release clicks it.
function M.carry(x, y)
local p = carry.pressed
if not p or not p.id then
return
end
-- (not only one under the pointer now: a full build plays the page's entrance, so the
-- new button may be elsewhere this frame; Druid checks the pointer at the release)
local found
for _, b in ipairs(carry.made) do
if test.id_of(b.node) == p.id and gui.is_enabled(b.node, true)
and helper.pick_node(b.node, x, y, b.click_zone) then
if test.id_of(b.node) == p.id and gui.is_enabled(b.node, true) then
b.can_action = true
b.last_pressed_time = p.t
b.hover:set_hover(true)
carry.pressed = { node = b.node, id = p.id, t = p.t }
return true
found = b
end
end
if found then
carry.pressed = { node = found.node, id = p.id, t = p.t }
return true
end
end
-- Draw and bind. Returns { root, nodes, w, h, button }.
+27 -1
View File
@@ -6,6 +6,10 @@
// mmwindow.set_fullscreen(true | false)
// mmwindow.is_fullscreen() -> bool
//
// Also sticky mouse buttons and keys: the engine polls button and key states once a frame,
// so a press and release between two polls (a quick click or a touchpad tap at a low frame
// rate) was lost; sticky, a press released before the poll still reads as pressed once.
//
// The GLFW functions are the engine's (statically linked); declared here, no header.
#define EXTENSION_NAME MMWindow
@@ -32,8 +36,12 @@ void glfwGetMonitorPos(GLFWmonitor* monitor, int* x, int* y);
void glfwGetWindowPos(GLFWwindow* window, int* x, int* y);
void glfwGetWindowSize(GLFWwindow* window, int* w, int* h);
void glfwSetWindowMonitor(GLFWwindow* window, GLFWmonitor* monitor, int x, int y, int w, int h, int refresh);
void glfwSetInputMode(GLFWwindow* window, int mode, int value);
}
#define MM_GLFW_STICKY_KEYS 0x00033002
#define MM_GLFW_STICKY_MOUSE_BUTTONS 0x00033003
static int g_X, g_Y, g_W, g_H;
static bool g_Saved = false;
@@ -128,9 +136,27 @@ static dmExtension::Result Initialize(dmExtension::Params* params)
return dmExtension::RESULT_OK;
}
// Sticky input on the engine's window, once it exists (see the top).
static bool g_Sticky = false;
static dmExtension::Result Update(dmExtension::Params* params)
{
#if defined(MM_GLFW)
if (!g_Sticky) {
GLFWwindow* win = glfwGetCurrentContext();
if (win) {
glfwSetInputMode(win, MM_GLFW_STICKY_MOUSE_BUTTONS, 1);
glfwSetInputMode(win, MM_GLFW_STICKY_KEYS, 1);
g_Sticky = true;
}
}
#endif
return dmExtension::RESULT_OK;
}
static dmExtension::Result Finalize(dmExtension::Params* params)
{
return dmExtension::RESULT_OK;
}
DM_DECLARE_EXTENSION(EXTENSION_NAME, LIB_NAME, 0, 0, Initialize, 0, 0, Finalize)
DM_DECLARE_EXTENSION(EXTENSION_NAME, LIB_NAME, 0, 0, Initialize, Update, 0, Finalize)
+5
View File
@@ -9,6 +9,7 @@
-- (else the one holding the focused element, else the biggest one), like a browser.
-- F11 toggles fullscreen; Ctrl + / Ctrl - / Ctrl 0 change the UI scale (modules/screen),
-- like the browser's zoom. F3 (debug builds) shows the engine's profiler (frame times).
-- G opens the quick grant dialog for admins (state.admin, screens/game/grant.lua).
--
-- Text fields made in a build register in g.inputs ({ input = Druid input, submit = fn,
-- step = { min, max } }: modules/field, the refinery's quantity); core/main resets the list
@@ -32,6 +33,7 @@ local CTRL = { [hash("key_lctrl")] = true, [hash("key_rctrl")] = true }
local F11 = hash("key_f11")
local F3 = hash("key_f3")
local ESC = hash("key_esc")
local KEY_G = hash("key_g")
local ZOOM = { [hash("key_plus")] = 1, [hash("key_equals")] = 1, [hash("key_numpad_add")] = 1,
[hash("key_minus")] = -1, [hash("key_numpad_subtract")] = -1, [hash("key_0")] = 0, [hash("key_numpad_0")] = 0 }
local UP, DOWN = hash("key_up"), hash("key_down")
@@ -243,6 +245,9 @@ function M.before(g, action_id, action, input)
elseif action_id == MOUSE_FORWARD or (action_id == RIGHT and g.mods.alt) then
session.go(g, 1)
return true
elseif action_id == KEY_G and not g.mods.ctrl and not g.mods.alt and g.state and g.state.admin then
require("screens.game.grant").open(g) -- (admins: the quick grant dialog)
return true
end
return false
end
+1 -1
View File
@@ -352,7 +352,7 @@ function M.build(g)
return banner.build(g, g.h, holder, W)
end)
local header_h = blocks.keep(g, "header", blocks.sig(common, W, banner_h, g.view.section, st.credits, st.resources,
st.unlocked, st.shine, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
g.tab_nodes = {}
local header_root = ui.rect(holder, 0, banner_h, W, 1, "none")
g.header_node = header_root
+91
View File
@@ -0,0 +1,91 @@
-- Quick grant (admins only, state.admin): give yourself credits, a resource or a refined
-- item (server action admin-grant). Opened by the header's grant button or the G key
-- (core/keys). An item picker and an amount picker (buttons, the pick ringed), then Grant.
-- Test ids: "dialog:grant:<item>", "dialog:amount:<n>", "dialog:ok".
local ui = require("modules.ui")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local theme = require("modules.theme")
local S = theme.size
local M = {}
local AMOUNTS = { { 100, "100" }, { 1000, "1K" }, { 10000, "10K" }, { 100000, "100K" }, { 1000000, "1M" } }
local W = 520
-- The choices: credits, then the catalog's resources and refined items.
local function items(st)
local out = { { "credits", "Credits" } }
local cat = st.catalog or {}
for _, list in ipairs({ cat.resources or {}, cat.refined or {} }) do
for _, r in ipairs(list) do
table.insert(out, { r.name, r.name })
end
end
return out
end
-- A wrapping row of small buttons (choices: { value, label }) at (x, y), inner width w.
local function picker(parent, choices, x, y, w)
local kids = {}
for i, c in ipairs(choices) do
kids[i] = { style = { width = fg.button_w(parent, c[2], { small = true }), height = S.button_sm_h, shrink = 0 } }
end
local tree = flex.box({ wrap = true, gap = 8, width = w }, kids)
flex.solve(tree, x, y)
return tree
end
function M.open(g)
local st = g.state
if not (st and st.admin) or g.dialog:is_open() then
return
end
local choices, amounts = items(st), {}
for i, a in ipairs(AMOUNTS) do
amounts[i] = { a[1], a[2] }
end
-- (laid out first: the dialog's height comes from it)
local x, y, iw = 24, 64, W - 48
local item_tree = picker(g.dialog.layer, choices, x, y + 22, iw)
local ay = y + 22 + item_tree.h + 20
local amount_tree = picker(g.dialog.layer, amounts, x, ay + 22, iw)
local h = ay + 22 + amount_tree.h + 24 + S.button_h + 24
local params = { grant = "credits", amount = "1000" }
g.dialog:open({
title = "Grant",
w = W,
h = h,
ok = { label = "Grant", act = "admin-grant", params = params },
build = function(d, box)
local rings = {}
local function pick(kind, tree, list, key, value_of)
for i, c in ipairs(list) do
local b = tree.children[i]
local function ring()
if rings[kind] then
gui.delete_node(rings[kind])
end
rings[kind] = ui.outline(box, b.x - 3, b.y - 3, b.w + 6, b.h + 6, "cyan")
end
d:button(box, c[2], b.x, b.y, { small = true, w = b.w, id = "dialog:" .. kind .. ":" .. tostring(c[1]),
on_click = function()
params[key] = value_of(c[1])
ring()
end })
if value_of(c[1]) == params[key] then
ring()
end
end
end
ui.text(box, "Item", "cap", x, y + 6)
pick("grant", item_tree, choices, "grant", tostring)
ui.text(box, "Amount", "cap", x, ay + 6)
pick("amount", amount_tree, amounts, "amount", function(n) return string.format("%d", n) end)
end,
})
end
return M
+11
View File
@@ -134,6 +134,17 @@ local function wallet(g, parent, W)
g.druid:new_button(disp, function()
require("screens.game.settings").open(g)
end):set_animations_disabled()
if st.admin then
-- Admins: the quick grant dialog (screens/game/grant.lua; G opens it too).
local gr = ui.outline(parent, 0, 0, 28, 28, "line_hi")
push(gr, 28, 28, 8)
ui.text(gr, "+", { font = "mono", px = 18, color = "cyan" }, 14, 14, gui.PIVOT_CENTER)
test.register("grant", gr, "button", "grant")
tooltip.add(gr, "Grant (G): credits, resources, refined items")
g.druid:new_button(gr, function()
require("screens.game.grant").open(g)
end):set_animations_disabled()
end
local tree = flex.solve({ style = { justify = "end", align = "center", padding_right = S.header_pad_x }, children = row }, 0, 16, W, 36)
flex.apply(tree)
return row[1].x - 10
+20 -5
View File
@@ -236,7 +236,8 @@ class Game:
self.window_origin()
if self.window_id:
self.xdo("windowfocus", "--sync", self.window_id)
# (the engine polls keys once a frame: a press and release between two frames is lost)
# (the engine polls keys once a frame; sticky keys (mmwindow) keep a press shorter
# than a frame, held like a hand anyway)
self.xdo("keydown", name)
time.sleep(0.08)
self.xdo("keyup", name)
@@ -359,10 +360,24 @@ def main():
if os.path.exists(disp):
os.environ["DISPLAY"] = open(disp).read().strip() # the headless make dev
elif not os.environ.get("DISPLAY_FOR_DRIVE"):
# Re-run inside a fresh Xvfb display of the wanted size.
cmd = ["xvfb-run", "-a", "-s", "-screen 0 %sx24" % args.size, sys.executable] + sys.argv
env = dict(os.environ, DISPLAY_FOR_DRIVE="1")
sys.exit(subprocess.call(cmd, env=env))
# Re-run inside a fresh Xvfb display of the wanted size. Xvfb picks a free display
# itself (-displayfd): xvfb-run -a races when two runs start together, and both
# games then share one display (each run's clicks land on the other's window).
r, w = os.pipe()
xvfb = subprocess.Popen(["Xvfb", "-displayfd", str(w), "-screen", "0", "%sx24" % args.size, "-nolisten", "tcp"],
pass_fds=(w,), stderr=subprocess.DEVNULL)
os.close(w)
num = os.read(r, 32).decode().strip()
os.close(r)
if not num:
raise SystemExit("Xvfb didn't start")
env = dict(os.environ, DISPLAY_FOR_DRIVE="1", DISPLAY=":" + num)
try:
code = subprocess.call([sys.executable] + sys.argv, env=env)
finally:
xvfb.terminate()
xvfb.wait()
sys.exit(code)
path = find_scenario(args.scenario)
name = os.path.splitext(os.path.basename(path))[0]
+7
View File
@@ -28,6 +28,7 @@ def alive(game, what):
target = next(t for t in TABS if t != section)
b0 = game.eval("g.builds")
n0 = game.eval("g.engine_inputs or 0")
game.eval("(function() g.il_touch = {} return true end)()")
ok = False
game.os_click("tab:" + target) # (once: a rebuild between the press and the release carries it)
for _ in range(15): # (the navigation started: with fake latency it lands seconds later)
@@ -36,6 +37,8 @@ def alive(game, what):
break
game.sleep(0.1)
got = game.eval("g.engine_inputs or 0") - n0
if not ok: # (what the engine delivered: touch presses and releases, where, over what)
print(" touches:", game.eval("g.il_touch"))
print(" %s: %d engine inputs, builds %d before the click" % (what, got, b0))
game.expect(ok and got > 0, "%s: a real click reaches the page (%d inputs)" % (what, got))
game.idle()
@@ -81,6 +84,10 @@ def backdrop(game):
def run(game, args):
fx = game.fixture
game.eval("(function() local m=require('screens.game.core.main') if not m.il_i then m.il_i=m.on_input "
"m.on_input=function(g,a,x) if a==hash('touch') and (x.pressed or x.released) and g.il_touch then "
"table.insert(g.il_touch,{p=x.pressed and 1 or 0,x=x.x,y=x.y,b=g.builds,d=g.dialog.root and 1 or 0,"
"busy=g.busy and 1 or 0,f=socket.gettime()}) end return m.il_i(g,a,x) end end return true end)()")
if "reduced" in args:
game.set("reduced_motion", "1")
game.idle()
+2 -1
View File
@@ -107,7 +107,8 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
sections and actions need a gate there.
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged as `admin_*` in
`event_log`). `notBanned` gives banned players (`users.banned_at`) a suspended page.
`event_log`). In the game, admins (`Page.Admin`, `state.admin`) also get a quick grant
dialog (header Grant button or G; action `admin-grant`, 403 for others). `notBanned` gives banned players (`users.banned_at`) a suspended page.
`web/templates/admin.html`: the shared admin nav (`{{template "admin-nav" "<page>"}}`, put
it on every `/utils` page) and confirm dialog. `/utils` redirects to `/utils/players`.
- `web/templates/`: `page.html` (page shell, boot page), `app.html` (header, moon
+7 -1
View File
@@ -48,6 +48,7 @@ type APIState struct {
Well *APIWell `json:"well"` // s=moons with a well selected
Market *APIMarket `json:"market"` // once the market is unlocked
Banner []APIBanner `json:"banner"`
Admin bool `json:"admin"` // may use admin-grant
// Always present (api_sections.go).
ShowNav bool `json:"showNav"` // the section tabs show (not while Moons would be the only one)
@@ -380,7 +381,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(),
Unlocked: map[string]bool{}, Shine: []string{}, Ready: p.Ready, Idle: p.Idle, NextEnd: unixMS(p.NextEnd),
Moons: apiMoonList(p.Moons), Past: apiMoonList(p.Past), Tools: []APITool{}, Stakes: []APIStake{},
Banner: []APIBanner{}}
Banner: []APIBanner{}, Admin: p.Admin}
for _, r := range s.Refined {
a.Refined = append(a.Refined, APIAmount{Name: r.Item, Amount: r.Balance})
}
@@ -674,6 +675,7 @@ func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, e
// catalog out, catalog=<catalogVersion> leaves it out while it's unchanged.
func (s *server) apiStateWith(ctx context.Context, page *Page, get func(string) string) (*APIState, error) {
page.Banner = s.banner(ctx)
page.Admin = s.isAdmin(page.S.UserID)
var extra []*Page
for _, section := range apiIncludes(get("include")) {
if section == page.V.Section {
@@ -765,6 +767,10 @@ func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
apiError(w, http.StatusNotFound, "Unknown action")
return
}
if errors.Is(err, errForbidden) {
apiError(w, http.StatusForbidden, "Missing permission")
return
}
if err != nil {
apiServerError(w, err)
return
+3 -1
View File
@@ -80,6 +80,7 @@ The other sections' actions:
| `dismiss-advisor` | `advisor` | frees their seat; nothing is refunded (confirm with `dismiss`) |
| `sell-moon` | `sell-moon` (moon id) | `moon.sale.canSell`; view moves to another moon |
| `new-game` | – | creates a new player and returns its id and state |
| `admin-grant` | `grant` (`credits`, a resource or a refined item name), `amount` | admins only (`state.admin`), else `403`; logged as `admin_grant` |
Responses:
- `200` `{ok: true, userId, clientSeq?, toast: {message, error: false, kind}, fx: {kind, finished, credits}, reveal?, buyModal?, state}`.
@@ -88,7 +89,7 @@ Responses:
(after `buy-tool`) are one-shot results, see below.
- `422` rule violation or locked action: `{ok: false, error, toast: {message, error: true, kind: "error"}, state}`.
A locked action (its part of the game not revealed yet) says "Not available yet".
- `400` bad body, `404` unknown action, `429` too many new players, `403` banned,
- `400` bad body, `404` unknown action, `429` too many new players, `403` banned or not an admin (`admin-grant`),
`500` `{ok: false, error: "Something went wrong"}`.
## GET /api/moon-settings
@@ -106,6 +107,7 @@ session; the web page also reads it once per page load.
| `userId`, `now` | |
| `view` | `{section, moon, well, past, page, pick, drill, adv, aside}`, resolved (`page` clamped, `adv` only a hired advisor in `s=advisors`); `aside`: the web shows the aside (viewport and side panel): moon view, new-moon teaser, an open drill or advisor panel |
| `credits` | balance |
| `admin` | the player is an admin (`ADMIN_USER_IDS`, or everyone with `MM_DEV=1`): may use `admin-grant` |
| `resources`, `refined` | `[{name, amount}]` |
| `moonPrice` | price of the next moon |
| `unlocked` | `{moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon, claim-all, redrill, redeploy}`: booleans; hide what is false |
+4
View File
@@ -18,7 +18,11 @@ func TestAPI(t *testing.T) {
s.pagePastInMemory(v.Page)
p := s.page(v.resolve(s))
testRanking(p)
p.Admin = len(s.Tools) > 0
a := apiState(p)
if a.Admin != p.Admin {
t.Errorf("view %+v: admin %v", v, a.Admin)
}
if _, err := json.Marshal(a); err != nil {
t.Fatalf("marshal %+v: %v", v, err)
}
+23
View File
@@ -344,6 +344,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
return
}
page.Banner = s.banner(r.Context())
page.Admin = s.isAdmin(userID)
if htmx {
page.OOB = true
s.render(w, "regions", page)
@@ -368,6 +369,9 @@ type actionResult struct {
// errUnknownAction is runAction's error for an action that doesn't exist.
var errUnknownAction = errors.New("unknown action")
// errForbidden is runAction's error for an admin action run by a player who isn't one (403).
var errForbidden = errors.New("missing permission")
// runAction runs a game action: its onboarding gate, then its rule in game.go. get reads
// the request's parameters, including the current view (s, moon, well, tab), which stays
// unless the action moves on. new-game only passes the gate; the caller creates the player.
@@ -570,6 +574,20 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
if n, err = buyAdvisorSeat(ctx, s.db, userID); err == nil {
message = fmt.Sprintf("Advisor seat %d added", n)
}
case "admin-grant":
// The quick grant dialog: an admin gives themselves credits, a resource or a refined item.
if !s.isAdmin(userID) {
return actionResult{}, errForbidden
}
item := get("grant")
amount, _ := strconv.ParseFloat(get("amount"), 64)
if item == "credits" {
err = adminCredits(ctx, s.db, userID, userID, amount, false)
message = "Granted " + formatCredits(amount)
} else {
err = adminResources(ctx, s.db, userID, userID, item, amount)
message = fmt.Sprintf("Granted %s %s", formatMoney(amount), item)
}
case "new-game":
default:
return actionResult{}, errUnknownAction
@@ -601,6 +619,10 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
http.NotFound(w, r)
return
}
if errors.Is(err, errForbidden) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
if err != nil {
serverError(w, err)
return
@@ -632,6 +654,7 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
return
}
page.Banner = s.banner(ctx)
page.Admin = s.isAdmin(userID)
page.OOB = true
page.Toast = toast
if res.BoughtTool != "" {
+1
View File
@@ -37,6 +37,7 @@ func TestTemplates(t *testing.T) {
s.pagePastInMemory(v.Page)
v = v.resolve(s)
p := s.page(v)
p.Admin = len(s.Tools) > 0 // the grant dialog and button, for some states
if v.Section == "leaderboard" {
p.Ranking = &LeaderboardPage{Players: 12, Top: []LeaderboardRow{{Rank: 1, Name: "Top Miner", Balance: 9000}},
You: &LeaderboardRow{Rank: 12, Name: "Test Miner", Balance: 10, Me: true}}
+7
View File
@@ -171,6 +171,13 @@ POST :host/api/act/claim
X-MM-User: :user_id
Content-Type: application/x-www-form-urlencoded
# Admins only (state.admin, else 403): grant yourself credits, a resource or a refined item
POST :host/api/act/admin-grant
X-MM-User: :user_id
Content-Type: application/json
{"grant": "credits", "amount": 1000}
# Sell resources (amount=<n> or all=1)
POST :host/api/act/sell
X-MM-User: :user_id
+10
View File
@@ -271,6 +271,15 @@ func (v View) ToDrill(id string) string {
// Page is the data for the whole screen. Every htmx response re-renders all of it as
// out-of-band swaps (see web/templates/app.html).
// RefinedNames lists every refined item, for the grant dialog.
func (p *Page) RefinedNames() []string {
var names []string
for _, item := range cfg().Refinery.Items {
names = append(names, item.Name)
}
return names
}
type Page struct {
S *State
V View
@@ -280,6 +289,7 @@ type Page struct {
Modal *BuyModal // opens after buying a tool
Banner []BannerItem // active announcements and live events (events.go)
Admin bool // the player is an admin: the quick grant dialog (admin-grant)
Ready int // finished stakes, all moons
NextEnd time.Time // when the soonest unfinished stake finishes (zero if none)
+9
View File
@@ -2101,6 +2101,15 @@ a.adv-seat-in[aria-current] {
line-height: 1.45;
}
.grant-fields {
display: flex;
gap: 0.5rem;
}
.grant-fields .input {
flex: 1;
min-width: 0;
}
.dialog-actions {
display: flex;
flex-wrap: wrap;
+28
View File
@@ -134,6 +134,34 @@ const dialogSound = (value) => sfx.play(value === "ok" ? "confirm" : "close");
wireDialog(dialog, { onClose: dialogSound });
confirmWith(dialog, { onOpen: () => sfx.play("open") });
// Admins: the quick grant dialog (#grant), from the header's Grant button or the G key.
const grant = document.getElementById("grant");
if (grant) {
const openGrant = () => {
if (document.querySelector("dialog[open]")) return;
grant.showModal();
sfx.play("open");
};
wireDialog(grant, {
onClose: (value) => {
dialogSound(value);
if (value !== "ok") return;
const f = grant.querySelector("form");
htmx.ajax("POST", "/act/admin-grant", { source: f, swap: "none",
values: { grant: f.elements.grant.value, amount: f.elements.amount.value } });
},
});
grant.querySelector("button[value=cancel]").addEventListener("click", () => closeDialog(grant, "cancel"));
document.addEventListener("click", (event) => {
if (event.target.closest("[data-grant-open]")) openGrant();
});
addEventListener("keydown", (event) => {
if (event.key !== "g" && event.key !== "G") return;
if (event.ctrlKey || event.metaKey || event.altKey || event.target.closest?.("input, select, textarea, [contenteditable]")) return;
openGrant();
});
}
// Instant feedback: everything is disabled while requests run (CSS: .is-busy).
let inFlight = 0;
document.body.addEventListener("htmx:beforeRequest", () => {
+23
View File
@@ -127,10 +127,33 @@
{{template "theme-toggle"}}
<!-- TODO: show only to admins once accounts/auth exist -->
<a href="/utils" class="top-btn">Admin</a>
{{if .Admin}}<button type="button" class="top-btn" data-grant-open title="Grant yourself anything (G)">Grant</button>{{end}}
</div>
</header>
{{end}}
{{/* Admins only: give yourself credits, a resource or a refined item (admin-grant). Opened
by the header's Grant button or the G key; main.js posts it when Grant closes it. */}}
{{define "grant-dialog"}}
<dialog id="grant" class="dialog frame" aria-labelledby="grant-title">
<form method="dialog">
<h2 id="grant-title" class="dialog-title">Grant</h2>
<div class="dialog-message grant-fields">
<select name="grant" class="input" aria-label="Item">
<option value="credits">₥ Credits</option>
{{range .S.Config.Resources}}<option>{{.}}</option>{{end}}
{{range .RefinedNames}}<option>{{.}}</option>{{end}}
</select>
<input name="amount" class="input num" type="number" min="0.01" step="any" value="1000" required aria-label="Amount">
</div>
<div class="dialog-actions">
<button type="button" value="cancel" class="btn">Cancel</button>
<button value="ok" class="btn btn-primary">Grant</button>
</div>
</form>
</dialog>
{{end}}
{{/* Claims every finished drill (the tools go back to the inventory). Shown while anything
is mining; disabled with a countdown until the first drill finishes (main.js enables
it then and keeps the ready count current; the server decides what is actually ready). */}}
+1
View File
@@ -63,6 +63,7 @@
<div id="toasts" class="toasts" aria-live="polite" hx-ext="ignore:morph"></div>
<div id="splash" class="splash" hidden></div>
{{template "confirm-dialog"}}
{{if .Admin}}{{template "grant-dialog" .}}{{end}}
<div id="modal"></div>
<div id="reveal" hidden></div>
{{template "view" .}}