starlit  Artifact [1ac6a2a876]

Artifact 1ac6a2a8764f90afe4a3c184ac2a72240842d74ecabc40b9d4cf430e7174633f:


-- [ʞ] 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

local leds = {
	freeze = {
		icon = lib.image('starlit-ui-alert-temp-cold.png');
		bg = lib.image('starlit-ui-alert-bg-temp-cold.png');
		side = 'left';
	};
	overheat = {
		icon = lib.image('starlit-ui-alert-temp-hot.png');
		bg = lib.image('starlit-ui-alert-bg-temp-hot.png');
		side = 'left';
	};
	hydration = {
		icon = lib.image('starlit-ui-alert-hydration.png');
		bg = lib.image('starlit-ui-alert-bg-hydration.png');
		side = 'left';
	};
	nutrition = {
		icon = lib.image('starlit-ui-alert-nutrition.png');
		bg = lib.image('starlit-ui-alert-bg-nutrition.png');
		side = 'left';
	};

	radiation = {
		icon = lib.image('starlit-ui-alert-rad.png');
		bg = lib.image('starlit-ui-alert-bg-rad.png');
		side = 'right';
	};
	fatigue = {
		icon = lib.image('starlit-ui-alert-fatigue.png');
		bg = lib.image('starlit-ui-alert-bg-fatigue.png');
		side = 'right';
	};
	item = {
		icon = lib.image('starlit-ui-alert-item.png');
		bg = lib.image('starlit-ui-alert-bg-success.png');
		side = 'right';
	};
}

starlit.type.user = lib.class {
	name = 'starlit:user';
	leds = leds;
	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 = {};
				bar = {};
				alarm = {};
				led = { left={}, right={}, map={} };
			};
			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';
			};
			overlays = {};
			cooldownTimes = {
				stamina = 0;
				alarm = 0;
			};
		}
	end;
	__index = {
		--------------
		-- overlays --
		--------------
		updateOverlays = function(self)
			-- minetest: because fuck you, that's why
			local engineGravity = starlit.constant.phys.engineGravity
			local targetGravity = starlit.world.planet.gravity
			local phys = {
				speed = self.pheno:trait('speed',1);
				jump = self.pheno:trait('jump',1);
				gravity = targetGravity / engineGravity;
				speed_climb = 1;
				speed_crouch = 1;
				speed_walk = 1;
				acceleration_default = 1;
				acceleration_air = 1;
			}
			for i, o in ipairs(self.overlays) do o(phys) end
			self.entity:set_physics_override(phys)
		end;
		overlay = function(self, o)
			local id = #self.overlays+1
			self.overlays[id] = o
			self:updateOverlays()
			return id
		end;
		deleteOverlay = function(self, id)
			table.remove(self.overlays, id)
			self:updateOverlays()
		end;

		--------------
		-- personae --
		--------------
		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;

		-----------
		-- stats --
		-----------
		statDelta = function(self, stat, d, cause, abs)
			if self.entity:get_hp() == 0 then return end
			local dt = self.persona.statDeltas
			local min, max, base = self:statRange(stat)
			if abs then
				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

				if     dt[stat]+base > max then dt[stat] = max-base
				elseif dt[stat]+base < min then dt[stat] = min-base end
				self:pushPersona()
			end


			local sb = self.hud.bar[stat]
			if sb then sb:update() end
-- 			self:updateHUD()
			-- TODO trigger relevant animations?
		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;

		---------------
		-- phenotype --
		---------------
		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;
		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;

		---------
		-- HUD --
		---------
		attachImage = function(self, def)
			local user = self.entity
			local img = {def = def}
			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 = {def = def}
			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,hl,hlcolor = def.measure(luser,def)
				v = math.max(0, math.min(1, v))
				local n = math.floor(v*16) + 1
				local function adjust(img)
					return hudAdjustBacklight(lib.image(img)):shift(color or def.color)
				end
				local img = adjust 'starlit-ui-meter.png'
				img = img:render()
				img = img .. '^[verticalframe:17:' .. tostring(17 - n)
				if hl then
					hl = math.floor(hl*16) + 1
					local hi = hudAdjustBacklight(lib.image 'starlit-ui-meter-hl.png')
						:shift(hlcolor or def.color)
						:render()
					hi = hi .. '^[verticalframe:17:' .. tostring(17 - hl)
					img = string.format('%s^(%s)', img, hi)
				end
				img = string.format('%s^(%s)', img, adjust 'starlit-ui-meter-readout.png':render())
				if def.flipX then
					img = img .. '^[transformFX'
				end
				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 = {def = def}
			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 = {def = def}
			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 attachBasicStat(def)
				local statName = def.stat
				def.stat = basicStat(def.stat)
				local b = self:attachStatBar(def)
				self.hud.bar[statName] = b
				return b
			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 = attachBasicStat {
				name = 'health', stat = 'health';
				color = C(10,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 = attachBasicStat {
				name = 'stamina', stat = '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.numina = attachBasicStat {
				name = 'numina', stat = 'numina';
				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 exposure = starlit.world.climate.temp(self.entity:get_pos())

					local function tempVals(warm, br)
						local n 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 hue = lib.math.gradient({
							205, 264, 281, 360 + 17
						}, (warm + 50) / 100) % 360
						return {hue=hue, sat = 1, lum = br}, n
					end

					local color, n = tempVals(warm,0)
					local hlcolor, hl = tempVals(exposure,.5)
					local txt = string.format("%s°", math.floor(warm))
					return (n/50), txt, color, nil, (hl/50), hlcolor
				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;
			}

			-- special-case the meters
			self.hud.bar.irradiation = self.hud.elt.geiger
			self.hud.bar.warmth = self.hud.elt.temp

			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;
			};
			self:updateHUD()
		end;
		deleteHUD = function(self)
			for name, e in pairs(self.hud.elt) do
				self.entity:hud_remove(e.id)
			end
		end;
		updateHUD = function(self)
			for name, e in pairs(self.hud.elt) do
				if e.update then e.update() end
			end
			self:updateLEDs()
		end;
		updateLEDs = function(self)
			local time = minetest.get_gametime()
			local function updateSide(name, ofs, tx)
				local del = {}
				for i, l in ipairs(self.hud.led[name]) do
					local idx = 0
					if time - l.origin > 3 then
						if l.elt then self.entity:hud_remove(l.elt.id) end
						self.hud.led.map[l.kind] = nil
						table.insert(del, i)
					else
						local xc = (idx*48 + 400)*ofs
						if l.elt and next(del) then
							l.elt:update('offset', {x=xc, y=1})
						else
							local tex = leds[l.kind].icon:blit(hudAdjustBacklight(leds[l.kind].bg))
							if tx then tex = lib.image(tex:render()):transform(tx) end
							if not l.elt then
								l.elt = self:attachImage {
									tex = tex:render();
									align = {x=ofs, y=-1};
									pos = {x=.5, y=1};
									scale = {x=1,y=1};
									ofs = {x=xc, y=0};
								}
							end
						end
						idx = idx + 1
					end
				end
				for _, i in ipairs(del) do
					table.remove(self.hud.led[name], i)
				end

			end
			updateSide('left', -1)
			updateSide('right', 1, 'FX')
		end;

		---------------------
		-- actions & modes --
		---------------------
		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;
		setModeHand = function(self) -- horrible horrible HACK
			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;

		---------------------
		-- intel-gathering --
		---------------------
		clientInfo = function(self)
			return minetest.get_player_information(self.name)
		end;
		species = function(self)
			return starlit.world.species.index[self.persona.species]
		end;
		-- can the suit heater sustain its current internal temperature in an area of t°C
		tempCanSustain = function(self, t)
			if self:naked() then return false end
			local s = self:getSuit()
			if s:powerState() == 'off' then return false end
			local sd = s:def()
			local w = self:effectiveStat 'warmth'
			local kappa = starlit.constant.heat.thermalConductivity
			local insul = sd.temp.insulation
			local dt = (kappa * (1-insul)) * (t - w)
			if (dt > 0 and          dt  > sd.temp.maxCool)
			or (dt < 0 and math.abs(dt) > sd.temp.maxHeat) then return false end
			return true
		end;
		-- will exposure to temperature t cause the player eventual harm
		tempHazard = function(self, t)
			local tr = self:species().tempRange.survivable
			if t >= tr[1] and t <= tr[2] then return nil end
			if self:tempCanSustain(t)    then return nil end

			if t < tr[1] then return 'cold' end
			return 'hot'
		end;

		--------------------
		-- event handlers --
		--------------------
		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 posrng = starlit.world.seedbank[0x13f19] -- TODO player-specific seed
			local cx = posrng:int(-500,500) --math.random(-500,500)
			local iter, startPoint = 1
			repeat local temp = -100
				local cz = posrng:int(-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
				iter = iter + 1
				if iter > 100 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:updateSuit()
		end;
		onRespawn = function(self)
			local meta = self.entity:get_meta()
			self.entity:set_pos(vector.from_string(meta:get_string'starlit_spawn'))
			self:statDelta('numina',          0, 'death', true)
			self:statDelta('nutrition', 1500, 'death', true)
			self:statDelta('hydration',    2, 'death', true)
			self:statDelta('fatigue',      0, 'death', true)
			self:statDelta('stamina',      0, 'death', true)
			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()
			self:updateOverlays()
		end;
		onPart = function(self)
			starlit.liveUI     [self.name] = nil
			starlit.activeUI   [self.name] = nil
			starlit.activeUsers[self.name] = nil
		end;

		-----------------------------
		-- environment suit & body --
		-----------------------------
		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;
		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()
			self.hud.elt.bat:update()
		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;

		--------
		-- ui --
		--------
		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;
		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
			elseif p.ref then
				run = p.ref.run
			else
				error('bad ability pointer ' .. dump(p))
			end
			if run then
				run(self, ctx)
				return true
			end
			return false
		end;

		alarm = function(self, urgency, kind, minFreq)
			minFreq = minFreq or 1.5
			local time = minetest.get_gametime()
			local led = leds[kind]

			local ul = self.hud.led.map[kind]
			if ul then
				if time - ul.origin > minFreq then
					ul.origin = time
				else return end
			end

			if urgency ~= 0 then
				local urgencies = {
					[-2] = {sound = 'starlit-success'};
					[-1] = {sound = 'starlit-nav'};
					[1] = {sound = 'starlit-alarm'};
					[2] = {sound = 'starlit-alarm-urgent'};
				}
			   local urg = urgencies[urgency] or urgencies[#urgencies]

			   if time - self.cooldownTimes.alarm > 1.5 then
				   self.cooldownTimes.alarm = time
				   self:suitSound(urg.sound)
			   end
		   end


			local newLed = {
				kind = kind;
				origin = time;
			}
			self.hud.led.map[kind] = newLed
			table.insert(self.hud.led[led.side], newLed)


		   self:updateLEDs()

		--[[
			freq = freq or 3
			local urgencies = {
				[1] = {sound = 'starlit-alarm'};
				[2] = {sound = 'starlit-alarm-urgent'};
			}
		   local gt = minetest.get_gametime()
		   local urg = urgencies[urgency] or urgencies[#urgencies]

		   if gt - self.cooldownTimes.alarm < freq then return end

		   self.cooldownTimes.alarm = gt
		   self:suitSound(urg.sound)

		   if where then
			   local elt = {
				   tex = where.tex or 'starlit-ui-alert.png';
				   scale = {x=1, y=1};
				   align = table.copy(where.elt.def.align);
				   pos = table.copy(where.elt.def.pos);
				   ofs = table.copy(where.elt.def.ofs);
			   }
			   elt.ofs.x = elt.ofs.x + where.ofs.x
			   elt.ofs.y = elt.ofs.y + where.ofs.y
			   local attached = self:attachImage(elt)
				table.insert(self.hud.alarm, attached)

			   -- HATE. HATE. HAAAAAAAAAAATE
			   minetest.after(freq/2, function()
				   for k,v in pairs(self.hud.alarm) do
					   self.entity:hud_remove(v.id)
				   end
				   self.hud.alarm={}
			   end)
		   end]]
	   end;

		-------------
		-- weather --
		-------------
		updateWeather = function(self)
		end;

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

		---------------
		-- inventory --
		---------------
		give = function(self, item)
			item = ItemStack(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 clockInterval = 1.0
starlit.startJob('starlit:clock', clockInterval, function(delta)
	for id, u in pairs(starlit.activeUsers) do
		u.hud.elt.time:update()
		u:updateLEDs()
		local ui = starlit.activeUI[u.name]
		if ui and (ui.self.refresh or ui.self.pages[ui.page].refresh) then
			ui.self:show(u)
		end
	end
end)

-- performs a general HUD refresh, mainly to update the HUD backlight brightness
local hudInterval = 10
starlit.startJob('starlit:hud-refresh', hudInterval, function(delta)
	for id, u in pairs(starlit.activeUsers) do
	u:updateHUD() end
end)

local biointerval = 1.0
starlit.startJob('starlit:bio', biointerval, function(delta)
	for id, u in pairs(starlit.activeUsers) do
		if u:effectiveStat 'health' ~= 0 then
			local bmr = u:phenoTrait 'metabolism' * biointerval
			-- TODO apply modifiers

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

			u:statDelta('nutrition', -bmr)
			u:statDelta('hydration', -dehydration)

			local moralePenalty = -1 -- 1min/min
			local fatiguePenalty = 1 -- 1min/min
			local heatPenalty = 1 -- stamina regen is divided by this

			do local warmth = u:effectiveStat 'warmth'
				local tempRange = u:species().tempRange
				local tComfMin, tComfMax = tempRange.comfort[1], tempRange.comfort[2]
				local tempDiff = 0
				if warmth < tComfMin then
					tempDiff = tComfMin - warmth
				elseif warmth > tComfMax then
					tempDiff = warmth-tComfMax
				end
-- 				print('tempDiff', tComfMin, tComfMax, tempDiff)
				local tempPenalty = tempDiff/3
				moralePenalty = moralePenalty + tempPenalty
				heatPenalty = heatPenalty + tempPenalty
			end

			-- penalize heavy phys. activity
			local stamina, sp = u:effectiveStat 'stamina'
			local fatigue, fp = u:effectiveStat 'fatigue'
			fatiguePenalty = fatiguePenalty * (1 + 9*(1-sp))
			local penaltyFromFatigue = 1 - fp

			local food = u:effectiveStat 'nutrition'
			local water = u:effectiveStat 'hydration'
			local rads = u:effectiveStat 'irradiation'
			if food < 1000 then moralePenalty = moralePenalty + (1 - (food/1000)) * 5 end
			if water < 1   then moralePenalty = moralePenalty + (1 - (water/1)) * 10 end

			if rads > 0 then
				u:statDelta('irradiation', -0.0001 * biointerval)
				local moraleDrainFac = 2^(rads / 2)
				moralePenalty = moralePenalty * moraleDrainFac
			end

			u:statDelta('morale', moralePenalty * biointerval)
			u:statDelta('fatigue', fatiguePenalty * biointerval)

			if food == 0 then -- starvation
				u:statDelta('health', -5*biointerval)
			end

			if water == 0 then -- dying of thirst
				u:statDelta('health', -20*biointerval)
			end

			if sp < 1.0 and minetest.get_gametime() - u.cooldownTimes.stamina > 5.0 then
				u:statDelta('stamina', (u:phenoTrait('staminaRegen',1) * penaltyFromFatigue) / heatPenalty)
-- 				print('stam', u:effectiveStat 'stamina', u:phenoTrait('staminaRegen',1) / heatPenalty, heatPenalty)
			end

			local morale, mp = u:effectiveStat 'morale'
			local pr = u:phenoTrait 'numinaRegen'
			u:statDelta('numina', pr * penaltyFromFatigue * mp)
		end
	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)
			local mManeuver = what(cbit.manv)
			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
			if mManeuver == mustInit then
				user:trigger('maneuver', {state='init'})
			elseif mManeuver == mustHalt then
				user:trigger('maneuver', {state='halt'})
			end
		end
		--bits = bit.band(bits, bit.bnot(skipBits))
		local function prog(what)
			user:trigger(what, {state='prog', delta=delta})
		end
		if bit.band(bits, cbit.dig)~=0  then prog 'primary'   end
		if bit.band(bits, cbit.put)~=0  then prog 'secondary' end
		if bit.band(bits, cbit.manv)~=0 then prog 'maneuver'  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)