starlit  Artifact [a0e5424f99]

Artifact a0e5424f99e8e9df2671899c259e6974170b33c4d50224b2ea35176137e1cd8e:


-- [ʞ] user.lua
--  ~ lexi hale <lexi@hale.su>
--  © EUPL v1.2
--  ? defines the starlit.type.user class, which is
--    the main interface between the game world and the
--    client. it provides for initial signup and join,
--    managing the HUD, skinning the player model,
--    effecting weather changes, etc.

local lib = starlit.mod.lib

local function hudAdjustBacklight(img)
	local night = math.abs(minetest.get_timeofday() - .5) * 2
	local opacity = night*0.8
	return img:fade(opacity)
end

local userStore = lib.marshal.metaStore {
	persona = {
		key  = 'starlit:persona';
		type = starlit.store.persona;
	};
}

local suitStore = starlit.store.suitMeta

starlit.type.user = lib.class {
	name = 'starlit:user';
	construct = function(ident)
		local name, luser
		if type(ident) == 'string' then
			name = ident
			luser = minetest.get_player_by_name(name)
		else
			luser = ident
			name = luser:get_player_name()
		end
		return {
			entity = luser;
			name = name;
			hud = {
				elt = {};
			};
			tree = {};
			action = {
				bits = 0; -- for control deltas
				prog = {}; -- for recording action progress on a node; reset on refocus
				tgt = {type='nothing'};
				sfx = {};
				fx = {};
			};
			actMode = 'off';
			power = {
				nano = {primary = nil, secondary = nil};
				weapon = {primary = nil, secondary = nil};
				psi = {primary = nil, secondary = nil};
				maneuver = nil;
			};
			pref = {
				calendar = 'commune';
			};
		}
	end;
	__index = {
		pullPersona = function(self)
			-- if later records are added in public updates, extend this function to merge them
			-- into one object
			local s = userStore(self.entity)
			self.persona = s.read 'persona'
			self.pheno = starlit.world.species.pheno(self.persona.species, self.persona.speciesVariant)
		end;
		pushPersona = function(self)
			local s = userStore(self.entity)
			s.write('persona', self.persona)
		end;
		uiColor = function(self) return lib.color {hue=238,sat=.5,lum=.5} end;
		statDelta = function(self, stat, d, cause, abs)
			local dt = self.persona.statDeltas
			local base
			if abs then
				local min, max
				min, max, base = self:statRange(stat)
				if     d == true  then d = max
				elseif d == false then d = min end
			end
			if stat == 'health' then
				self.entity:set_hp(abs and d or (self.entity:get_hp() + d), cause)
			elseif stat == 'breath' then
				self.entity:set_breath(abs and d or (self.entity:get_breath() + d))
			else
				if abs then
					dt[stat] = d - base
				else
					dt[stat] = dt[stat] + d
				end
				self:pushPersona()
			end
			self:updateHUD()
			-- TODO trigger relevant animations?
		end;
		lookupSpecies = function(self)
			return starlit.world.species.lookup(self.persona.species, self.persona.speciesVariant)
		end;
		phenoTrait = function(self, trait, dflt)
-- 			local s,v = self:lookupSpecies()
-- 			return v.traits[trait] or s.traits[trait] or 0
			return self.pheno:trait(trait, dflt)
		end;
		statRange = function(self, stat) --> min, max, base
			return starlit.world.species.statRange(
				self.persona.species, self.persona.speciesVariant, stat)
		end;
		effectiveStat = function(self, stat)
			local val
			local min, max, base = self:statRange(stat)

			if stat == 'health' then
				val = self.entity:get_hp()
			elseif stat == 'breath' then
				val = self.entity:get_breath()
			else
				val = base + self.persona.statDeltas[stat] or 0
			end

			local d = max - min
			return val, (val - min) / d
		end;
		damageModifier = function(self, kind, amt)
			if kind == 'bluntForceTrauma' then
				local std = self:phenoTrait 'sturdiness'
				if std < 0 then
					amt = amt / 1+std
				else
					amt = amt * 1-std
				end
			end
			return amt
		end;
		attachImage = function(self, def)
			local user = self.entity
			local img = {}
			img.id = user:hud_add {
				type = 'image';
				text = def.tex;
				scale = def.scale;
				alignment = def.align;
				position = def.pos;
				offset = def.ofs;
				z_index = def.z;
			}
			if def.update then
				img.update = function()
					def.update(user, function(prop, val)
						user:hud_change(img.id, prop, val)
					end, def)
				end
			end
			return img
		end;
		attachMeter = function(self, def)
			local luser = self.entity
			local m = {}
			local w = def.size or 80
			local szf = w / 80
			local h = szf * 260
			m.meter = luser:hud_add {
				type = 'image';
				scale = {x = szf, y = szf};
				alignment = def.align;
				position = def.pos;
				offset = def.ofs;
				z_index = def.z or 0;
			}
			local cx = def.ofs.x + (w/2)*def.align.x
			local cy = def.ofs.y + (h/2)*def.align.y
			local oy = cy + h/2 - 42
			-- this is so fucking fragile holy fuck
			m.readout = luser:hud_add {
				type = 'text';
				scale = {x = w, y = h};
				size = szf;
				style = 4;
				position = def.pos;
				alignment = {x=0,0};
				offset = {x = cx, y = oy};
				z_index = (def.z or 0)+1;
				number = 0xffffff;
			}
			m.destroy = function()
				luser:hud_remove(m.meter)
				luser:hud_remove(m.readout)
			end
			m.update = function()
				local v,txt,color,txtcolor = def.measure(luser,def)
				v = math.max(0, math.min(1, v))
				local n = math.floor(v*16) + 1
				local img = hudAdjustBacklight(lib.image('starlit-ui-meter.png'))
					:colorize(color or def.color)
				if def.flipX then
					img = img:transform 'FX'
				end
				img = img:render()
				img = img .. '^[verticalframe:17:' .. tostring(17 - n)
				luser:hud_change(m.meter, 'text', img)
				if txt then
					luser:hud_change(m.readout, 'text', txt)
				end
				if txtcolor then
					luser:hud_change(m.readout, 'number', txtcolor:hex())
				end
			end
			return m
		end;
		attachTextBox = function(self, def)
			local luser = self.entity
			local box = {}
			box.id = luser:hud_add {
				type = 'text';
				text = '';
				alignment = def.align;
				number = def.color and def.color:int24() or 0xFFffFF;
				scale = def.bound;
				size = {x = def.size, y=0};
				style = def.style;
				position = def.pos;
				offset = def.ofs;
			}
			box.update = function()
				local text, color = def.text(self, box, def)
				luser:hud_change(box.id, 'text', text)
				if color then
					luser:hud_change(box.id, 'number', color:int24())
				end
			end
			return box
		end;
		attachStatBar = function(self, def)
			local luser = self.entity
			local bar = {}
			local img = lib.image 'starlit-ui-bar.png'
			local colorized = img
			if type(def.color) ~= 'function' then
				colorized = colorized:shift(def.color)
			end

			bar.id = luser:hud_add {
				type = 'statbar';
				position = def.pos;
				offset = def.ofs;
				name = def.name;
				text = colorized:render();
				text2 = img:tint{hue=0, sat=-1, lum = -0.5}:fade(0.5):render();
				number = def.size;
				item = def.size;
				direction = def.dir;
				alignment = def.align;
				size = {x=4,y=24};
			}
			bar.update = function()
				local sv, sf = def.stat(self, bar, def)
				luser:hud_change(bar.id, 'number', def.size * sf)
				if type(def.color) == 'function' then
					local clr = def.color(sv, luser, sv, sf)
					luser:hud_change(bar.id, 'text', img:tint(clr):render())
				end
			end
			return bar, {x=3 * def.size, y=16} -- x*2??? what
		end;
		createHUD = function(self)
			local function basicStat(statName)
				return function(user, bar)
					return self:effectiveStat(statName)
				end
			end
			local function batteryLookup(user)
				local max = user:suitPowerCapacity()
				if max == 0 then return 0, 0 end
				local ch = user:suitCharge()
				return (ch/max)*100, ch/max
			end
			local function C(h,s,l) return {hue=h,sat=s,lum=l} end
			local hbofs = (1+self.entity:hud_get_hotbar_itemcount()) * 25
			local bpad = 8
			self.hud.elt.health = self:attachStatBar {
				name = 'health', stat = basicStat 'health';
				color = C(340,0,.3), size = 100;
				pos = {x=0.5, y=1}, ofs = {x = -hbofs, y=-48 - bpad};
				dir = 1;
				align = {x=-1, y=-1};
			}
			self.hud.elt.stamina = self:attachStatBar {
				name = 'stamina', stat = basicStat 'stamina';
				color = C(60,0,.2), size = 100;
				pos = {x=0.5, y=1}, ofs = {x = -hbofs, y=-24 - bpad};
				dir = 1;
				align = {x=-1, y=-1};
			}
			self.hud.elt.bat = self:attachStatBar {
				name = 'battery', stat = batteryLookup;
				color = C(190,0,.2), size = 100;
				pos = {x=0.5, y=1}, ofs = {x = hbofs - 4, y=-48 - bpad};
				dir = 0;
				align = {x=1, y=-1};
			}
			self.hud.elt.psi = self:attachStatBar {
				name = 'psi', stat = basicStat 'psi';
				color = C(320,0,.2), size = 100;
				pos = {x=0.5, y=1}, ofs = {x = hbofs - 4, y=-24 - bpad};
				dir = 0;
				align = {x=1, y=-1};
			}
			self.hud.elt.time = self:attachTextBox {
				name = 'time';
				align = {x=0, y=1};
				pos = {x=0.5, y=1};
				ofs = {x=0,y=-95};
				text = function(user)
					local cal = starlit.world.time.calendar[user.pref.calendar]
					return cal.time(minetest.get_timeofday())
				end;
			}
			self.hud.elt.temp = self:attachMeter {
				name = 'temp';
				align = {x=1, y=-1};
				pos = {x=0, y=1};
				ofs = {x=20, y=-20};
				measure = function(user)
					local warm = self:effectiveStat 'warmth'
					local n, color if warm < 0 then
						n = math.min(100, -warm)
						color = lib.color(0.1,0.3,1):lerp(lib.color(0.7, 1, 1), math.min(1, n/50))
					else
						n = math.min(100,  warm)
						color = lib.color(0.1,0.3,1):lerp(lib.color(1, 0, 0), math.min(1, n/50))
					end
					local txt = string.format("%s°", math.floor(warm))
					return (n/50), txt, color
				end;
			}
			self.hud.elt.geiger = self:attachMeter {
				name = 'geiger';
				align = {x=-1, y=-1};
				pos = {x=1, y=1};
				ofs = {x=-20, y=-20};
				flipX = true;
				measure = function(user)
					local hot = self:effectiveStat 'irradiation'
					local color = self:uiColor():lerp(lib.color(0.3, 1, 0), math.min(1, hot/5))
					local txt = string.format("%sGy", math.floor(hot))
					return (hot/5), txt, color
				end;
			}
			self.hud.elt.crosshair = self:attachImage {
				name = 'crosshair';
				tex = '';
				pos = {x=.5, y=.5};
				scale = {x=1,y=1};
				ofs = {x=0, y=0};
				align = {x=0, y=0};
				update = function(user, set)
					local imgs = {
						off = '';
						nano = 'starlit-ui-crosshair-nano.png';
						psi = 'starlit-ui-crosshair-psi.png';
						weapon = 'starlit-ui-crosshair-weapon.png';
					}
					set('text', imgs[self.actMode] or imgs.off)
				end;
			};
			local hudCenterBG = lib.image 'starlit-ui-hud-bg.png':colorize(self:uiColor())
			self.hud.elt.bg = self:attachImage {
				name = 'hudBg';
				tex = hudCenterBG:render();
				pos = {x=.5, y=1};
				scale = {x=1,y=1};
				ofs = {x=0, y=0};
				align = {x=0, y=-1};
				z = -1;
				update = function(user, set)
					set('text', hudAdjustBacklight(hudCenterBG):render())
				end;
			};
		end;
		-- horrible horrible HACK
		setModeHand = function(self)
			local inv = self.entity:get_inventory()
			local hnd
			if self.actMode == 'off'
				then hnd = ItemStack('starlit:_hand_dig')
				else hnd = ItemStack()
			end
			inv:set_stack('hand', 1, hnd)
		end;
		onModeChange = function(self, oldMode, silent)
			self.hud.elt.crosshair.update()
			if not silent then
				local sfxt = {
					off = 'starlit-mode-off';
					nano = 'starlit-mode-nano';
					psi = 'starlit-mode-psi';
					weapon = 'starlit-mode-weapon';
				}
				local sfx = self.actMode and sfxt[self.actMode] or sfxt.off
				self:suitSound(sfx)
				self:setModeHand()
			end
		end;
		actModeSet = function(self, mode, silent)
			if not mode then mode = 'off' end
			local oldMode = self.actMode
			self.actMode = mode
			self:onModeChange(oldMode, silent)
			if mode ~= oldMode then
				starlit.ui.setupForUser(self)
			end
		end;
		deleteHUD = function(self)
			for name, e in pairs(self.hud.elt) do
				self:hud_delete(e.id)
			end
		end;
		updateHUD = function(self)
			for name, e in pairs(self.hud.elt) do
				if e.update then e.update() end
			end
		end;
		clientInfo = function(self)
			return minetest.get_player_information(self.name)
		end;
		onSignup = function(self)
			local meta = self.entity:get_meta()
			local inv = self.entity:get_inventory()
			-- the sizes indicated here are MAXIMA. limitations on e.g. the number of elements that may be carried are defined by your suit and enforced through callbacks and UI generation code, not inventory size
			inv:set_size('main', 6) -- carried items and tools. main hotbar.
			inv:set_size('hand', 1) -- horrible hack to allow both tools and intrinsics

			inv:set_size('starlit_suit', 1) -- your environment suit (change at wardrobe)
			inv:set_size('starlit_cfg', 1) -- the item you're reconfiguring / container you're accessing

			local scenario
			for _, e in pairs(starlit.world.scenario) do
				if e.id == starlit.world.defaultScenario then
					scenario = e break
				end
			end assert(scenario)
			self.persona = starlit.world.species.birth(scenario.species, scenario.speciesVariant, self.entity)
			self.persona.name = self.entity:get_player_name() -- a reasonable default
			self.persona.background = starlit.world.defaultScenario
			self:pushPersona()

			local gifts = scenario.startingItems
			local inv = self.entity:get_inventory()
			inv:set_stack('starlit_suit', 1, starlit.item.mk(gifts.suit, self, {gift=true}))
			self:getSuit():establishInventories(self.entity)

			local function giveGifts(name, list)
				if inv:get_size(name) > 0 then
					for i, e in ipairs(list) do
						inv:add_item(name, starlit.item.mk(e, self, {gift=true}))
					end
				end
			end

			giveGifts('starlit_suit_bat', gifts.suitBatteries)
			giveGifts('starlit_suit_chips', gifts.suitChips)
			giveGifts('starlit_suit_guns', gifts.suitGuns)
			giveGifts('starlit_suit_ammo', gifts.suitAmmo)
			giveGifts('starlit_suit_canisters', gifts.suitCans)

			giveGifts('main', gifts.carry)

			self:reconfigureSuit()

			-- i feel like there has to be a better way
			local cx = math.random(-500,500)
			local startPoint
			repeat local temp = -100
				local cz = math.random(-500,500)
				local cy = minetest.get_spawn_level(cx, cz)
				if cy then
					startPoint = vector.new(cx,cy,cz)
					temp = starlit.world.climate.eval(startPoint,.5,.5).surfaceTemp
				end
				if cx > 10000 then break end -- avoid infiniloop in pathological conditions
			until temp > -2
			self.entity:set_pos(startPoint)
			meta:set_string('starlit_spawn', startPoint:to_string())
		end;
		onDie = function(self, reason)
			local inv = self.entity:get_inventory()
			local where = self.entity:get_pos()
			local function dropInv(lst)
				local l = inv:get_list(lst)
				for i, o in ipairs(l) do
					if o and not o:is_empty() then
						minetest.item_drop(o, self.entity, where)
					end
				end
				inv:set_list(lst, {})
			end
			dropInv 'main'
			dropInv 'starlit_suit'
			self:statDelta('psi',     0, 'death', true)
			self:statDelta('hunger',  0, 'death', true)
			self:statDelta('thirst',  0, 'death', true)
			self:statDelta('fatigue', 0, 'death', true)
			self:statDelta('stamina', 0, 'death', true)
			self:updateSuit()
		end;
		onRespawn = function(self)
			local meta = self.entity:get_meta()
			self.entity:set_pos(vector.from_string(meta:get_string'starlit_spawn'))
			self:updateSuit()
			return true
		end;
		onJoin = function(self)
			local me = self.entity
			local meta = me:get_meta()
			self:pullPersona()
			self:setModeHand()

			-- formspec_version and real_coordinates are apparently just
			-- completely ignored here
			me:set_formspec_prepend [[
				bgcolor[#00000000;true]
				style_type[button,button_exit,image_button,item_image_button;border=false]
				style_type[button;bgimg=starlit-ui-button-hw.png;bgimg_middle=8;content_offset=0,-2]
				style_type[button:hovered;bgimg=starlit-ui-button-hw-hover.png;bgimg_middle=8]
				style_type[button:pressed;bgimg=starlit-ui-button-hw-press.png;bgimg_middle=8;content_offset=0,1]
			]]
			local hotbarSlots = me:get_inventory():get_size 'main';
-- 			local slotTex = 'starlit-ui-slot.png'
-- 			local hbimg = string.format('[combine:%sx128', 128 * hotbarSlots)
-- 			for i = 0, hotbarSlots-1 do
-- 				hbimg = hbimg .. string.format(':%s,0=%s', 128 * i, slotTex)
-- 			end
			--me:hud_set_hotbar_image(lib.image(hbimg):colorize(self:uiColor()):fade(.36):render())
-- 			me:hud_set_hotbar_selected_image(lib.image(slotTex):colorize(self:uiColor()):render())
			me:hud_set_hotbar_image('[fill:1x24:0,0:' .. self:uiColor():fade(.1):hex())
			me:hud_set_hotbar_selected_image(
				'[fill:1x24,0,0:' .. self:uiColor():fade(.4):hex() .. '^[fill:1x1:0,23:#ffFFffff'
			)
			me:hud_set_hotbar_itemcount(hotbarSlots)
			me:hud_set_flags {
				hotbar = true;
				healthbar = false;
				breathbar = false;
				basic_debug = false;
				crosshair = false;
			}
			-- disable builtin crafting
			local inv = me:get_inventory()
				inv:set_size('craftpreview', 0)
				inv:set_size('craftresult', 0)
				inv:set_size('craft', 0)

			me:set_stars {
				day_opacity = 0.7;
			}
			me:set_sky {
				sky_color = {
					  day_sky = '#a7c2cd',   day_horizon = '#ddeeff';
					 dawn_sky = '#003964',  dawn_horizon = '#87ebff';
					night_sky = '#000000', night_horizon = '#000E29';
					fog_sun_tint = '#72e4ff';
					fog_moon_tint = '#2983d0';
					fog_tint_type = 'custom';
				};
				fog = { -- not respected??
					-- TODO make this seasonal & vary with weather
					fog_distance = 40;
					fog_start = 0.3;
				};
			}
			me:set_sun {
				texture = 'starlit-sun.png';
				sunrise = 'sunrisebg.png^[hsl:180:1:.7';
				tonemap = 'sun_tonemap.png^[hsl:180:1:.7';
				scale = 0.8;
			}
			me:set_lighting {
				shadows = {
					intensity = .5;
				};
				exposure = {
					luminance_max = 3.0;
					speed_dark_bright = 0.5;
					speed_bright_dark = 1.0;
				};
				volumetric_light = {
					strength = 0.3;
				};
			}
			me:set_eye_offset(nil, vector.new(3,-.2,10))
			-- TODO set_clouds speed in accordance with wind
			starlit.world.species.setupEntity(me, self.persona)
			starlit.ui.setupForUser(self)
			self:createHUD()
			self:updateSuit()
		end;
		suitStack = function(self)
			return self.entity:get_inventory():get_stack('starlit_suit', 1)
		end;
		suitSound = function(self, sfx)
			-- trigger a sound effect from the player's suit computer
			minetest.sound_play(sfx, {object=self.entity, max_hear_distance=4}, true)
		end;
		suitPowerStateSet = function(self, state, silent)
			-- necessary to enable reacting to power state changes
			-- e.g. to play sound effects, display warnings
			local os
			self:forSuit(function(s)
				os=s:powerState()
				s:powerStateSet(state)
			end)
			if state == 'off' then
				if self.actMode == 'nano' or self.actMode == 'weapon' then
					self:actModeSet('off', silent)
				end
			end
			if not silent and os ~= state then
				local sfx
				if state == 'off' then
					sfx = 'starlit-power-down'
				elseif os == 'off' then
					sfx = 'starlit-power-up'
				elseif state == 'powerSave' or os == 'powerSave' then
					sfx = 'starlit-configure'
				end
				if sfx then self:suitSound(sfx) end
			end
		end;
		species = function(self)
			return starlit.world.species.index[self.persona.species]
		end;
		updateBody = function(self)
			local adornment = {}
			local suitStack = self:suitStack()
			if suitStack and not suitStack:is_empty() then
				local suit = suitStack:get_definition()._starlit.suit
				suit.adorn(adornment, suitStack, self.persona)
			end
			starlit.world.species.updateTextures(self.entity, self.persona, adornment)
		end;
		updateSuit = function(self)
			self:updateBody()
			local inv = self.entity:get_inventory()
			local sst = suitStore(self:suitStack())
			if self:naked() then
				starlit.type.suit.purgeInventories(self.entity)
				if self.actMode == 'nano' or self.actMode == 'weapon' then
					self:actModeSet 'off'
				end
			else
				local suit = self:getSuit()
				suit:establishInventories(self.entity)

				if self:suitCharge() <= 0 then
					self:suitPowerStateSet 'off'
				end
			end
			self:updateHUD()
		end;
		reconfigureSuit = function(self)
			-- and here's where things get ugly
			-- you can't have an inventory inside another item. to hack around this,
			-- we use the player as the location of the suit inventories, and whenever
			-- there's a change in the content of these inventories, this function is
			-- called to serialize those inventories out to the suit stack
			if self:naked() then return end
			local suit = self:getSuit()
			suit:onReconfigure(self.entity:get_inventory())
			self:setSuit(suit)

			-- reconfiguring the suit can affect player abilities: e.g. removing
			-- / inserting a chip with a minimap program
		end;
		getSuit = function(self)
			local st = self:suitStack()
			if st:is_empty() then return nil end
			return starlit.type.suit(st)
		end;
		setSuit = function(self, suit)
			self.entity:get_inventory():set_stack('starlit_suit', 1, suit.item)
		end;
		changeSuit = function(self, ...)
			self:setSuit(...)
			self:updateSuit()
		end;
		forSuit = function(self, fn)
			local s = self:getSuit()
			if fn(s) ~= false then
				self:setSuit(s)
			end
		end;
		suitPowerCapacity = function(self) -- TODO optimize
			if self:naked() then return 0 end
			return self:getSuit():powerCapacity()
		end;
		suitCharge = function(self) -- TODO optimize
			if self:naked() then return 0 end
			return self:getSuit():powerLeft()
		end;
		suitDrawCurrent = function(self, power, time, whatFor, min)
			if self:naked() then return 0,0 end
			local inv = self.entity:get_inventory()
			local bl = inv:get_list('starlit_suit_bat')
			local supply = 0
			local wasteHeat = 0 --TODO handle internally
			for slot, ps in ipairs(bl) do
				if not ps:is_empty() then
					local p, h = starlit.mod.electronics.dynamo.drawCurrent(ps, power - supply, time)
					supply = supply + p
					wasteHeat = wasteHeat + h
					if power-supply <= 0 then break end
				end
			end
			if min and supply < min then return 0,0 end
			inv:set_list('starlit_suit_bat', bl)
			self:reconfigureSuit()
			if whatFor then
				-- TODO display power use icon
			end
			return supply, wasteHeat
		end;
		naked = function(self)
			return self:suitStack():is_empty()
		end;
		onPart = function(self)
			starlit.liveUI     [self.name] = nil
			starlit.activeUI   [self.name] = nil
			starlit.activeUsers[self.name] = nil
		end;
		openUI = function(self, id, page, ...)
			local ui = assert(starlit.interface.db[id])
			ui:open(self, page, ...)
		end;
		onRespond = function(self, ui, state, resp)
			ui:action(self, state, resp)
		end;

		updateWeather = function(self)
		end;

		canInteract = function(self, with)
			return true; -- TODO
		end;

		trigger = function(self, which, how)
			local p
			local wld = self.entity:get_wielded_item()
			if which == 'maneuver' then
				p = self.power.maneuver
			elseif which == 'retarget' then
				self.action.prog = {}
			elseif wld and not wld:is_empty() then
				local wdef = wld:get_definition()
				if wdef._starlit and wdef._starlit.tool then
					p = {tool = wdef._starlit.tool}
				end
			elseif self.actMode ~= 'off' then
				p = self.power[self.actMode][which]
			end
			if p == nil then return false end
			local ctx, run = {
				how = how;
			}
			if p.chipID then
				local inv = self.entity:get_inventory()
				local chips = inv:get_list 'starlit_suit_chips'
				for chSlot, ch in pairs(chips) do
					if ch and not ch:is_empty() then
						local d = starlit.mod.electronics.chip.read(ch)
						if d.uuid == p.chipID then
							local pgm = assert(d.files[p.pgmIndex], 'file missing for ability')
							ctx.file = starlit.mod.electronics.chip.fileHandle(ch, p.pgmIndex)
							ctx.saveChip = function()
								inv:set_slot('starlit_suit_chips', chSlot, ch)
							end
							local sw = starlit.item.sw.db[pgm.body.pgmId]
							run = assert(sw.run, 'missing run() for active software ability ' .. pgm.body.pgmId)
							break
						end
					end
				end
			else
				error('bad ability pointer ' .. dump(p))
			end
			if run then
				run(self, ctx)
				return true
			end
			return false
		end;
		give = function(self, item)
			local inv = self.entity:get_inventory()
			local function is(grp)
				return minetest.get_item_group(item:get_name(), grp) ~= 0
			end
			-- TODO notif popups
			if is 'specialInventory' then
			--[[
				if is 'powder' then
					if self:naked() then return item end
					local cans = inv:get_list 'starlit_suit_canisters'
					if cans and next(cans) then for i, st in ipairs(cans) do
						local lst = string.format('starlit_canister_%u_elem', i)
						item = inv:add_item(lst, item)
						if item:is_empty() then break end
					end end
					self:forSuit(function(x) x:pushCanisters(inv) end)
				end
				return item
				]]
			else
				return inv:add_item('main', item)
			end
		end;
		thrustUpon = function(self, item)
			local r = self:give(st)
			if not r:is_empty() then
				return minetest.add_item(self.entity:get_pos(), r)
			end
		end;
		consume = function(self, stack, n)
			n = n or 1
			if n == 0 then n = stack:get_count() end
			local fd = stack:take_item(n)
			local stats = starlit.world.food.effectiveStats(fd)

			return stack
		end;
	};
}

local biointerval = 3.0
starlit.startJob('starlit:bio', biointerval, function(delta)
	for id, u in pairs(starlit.activeUsers) do
		local p = u.pheno
		local bmr = p:trait 'metabolism' * biointerval
		-- TODO apply modifiers

		local dehydration = p:trait 'dehydration' * biointerval
		-- you dehydrate faster in higher temp
		dehydration = dehydration * math.max(1, starlit.world.climate.temp(u.entity:get_pos()) / 10)

		u:statDelta('hunger', bmr)
		u:statDelta('thirst', dehydration)
	end
end)

local cbit = {
	up   = 0x001;
	down = 0x002;
	left = 0x004;
	right= 0x008;
	jump = 0x010;
	manv = 0x020;
	snk  = 0x040;
	dig  = 0x080;
	put  = 0x100;
	zoom = 0x200;
}
-- this is the painful part
minetest.register_globalstep(function(delta)
	local doNothing,mustInit,mustHalt = 0,1,2
	for id, user in pairs(starlit.activeUsers) do
		local ent = user.entity
		local bits = ent:get_player_control_bits()

		local function what(b)
			if bit.band(bits, b) ~= 0 and bit.band(user.action.bits, b) == 0 then
				return mustInit
			elseif bit.band(bits, b) == 0 and bit.band(user.action.bits, b) ~= 0 then
				return mustHalt
			else return doNothing end
		end
		local skipBits = 0
		if user.action.bits ~= bits then
			local mPrimary = what(cbit.dig)
			local mSecondary = what(cbit.put)
			if mPrimary == mustInit then -- ENGINE-BUG
				user.action.tgt = {type='nothing'}
				user.action.prog = {}
			elseif mPrimary == mustHalt then
				user:trigger('primary', {state='halt'})
			end
			if mSecondary == mustHalt then
				user:trigger('secondary', {state='halt'})
			end
		end
		--bits = bit.band(bits, bit.bnot(skipBits))
		if bit.band(bits, cbit.dig)~=0 then
			user:trigger('primary', {state='prog', delta=delta})
		end
		if bit.band(bits, cbit.put)~=0 then
			user:trigger('secondary', {state='prog', delta=delta})
		end
		user.action.bits = bits
		-- ENGINE-BUG: dig and put are not handled equally in the
		-- engine. it is possible for the put bit to get stuck on
		-- if the key is hammered while the player is not moving.
		-- the bit will release as soon as the player looks or turns
		-- nonetheless this is obnoxious
	end
end)