new mod: bulletin_board
21
bulletin_boards/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 FaceDeer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
421
bulletin_boards/i18n.py
Normal file
@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Script to generate the template file and update the translation files.
|
||||
# Copy the script into the mod or modpack root folder and run it there.
|
||||
#
|
||||
# Copyright (C) 2019 Joachim Stolberg, 2020 FaceDeer, 2020 Louis Royer
|
||||
# LGPLv2.1+
|
||||
#
|
||||
# See https://github.com/minetest-tools/update_translations for
|
||||
# potential future updates to this script.
|
||||
|
||||
from __future__ import print_function
|
||||
import os, fnmatch, re, shutil, errno
|
||||
from sys import argv as _argv
|
||||
|
||||
# Running params
|
||||
params = {"recursive": False,
|
||||
"help": False,
|
||||
"mods": False,
|
||||
"verbose": False,
|
||||
"folders": []
|
||||
}
|
||||
# Available CLI options
|
||||
options = {"recursive": ['--recursive', '-r'],
|
||||
"help": ['--help', '-h'],
|
||||
"mods": ['--installed-mods'],
|
||||
"verbose": ['--verbose', '-v']
|
||||
}
|
||||
|
||||
# Strings longer than this will have extra space added between
|
||||
# them in the translation files to make it easier to distinguish their
|
||||
# beginnings and endings at a glance
|
||||
doublespace_threshold = 60
|
||||
|
||||
def set_params_folders(tab: list):
|
||||
'''Initialize params["folders"] from CLI arguments.'''
|
||||
# Discarding argument 0 (tool name)
|
||||
for param in tab[1:]:
|
||||
stop_param = False
|
||||
for option in options:
|
||||
if param in options[option]:
|
||||
stop_param = True
|
||||
break
|
||||
if not stop_param:
|
||||
params["folders"].append(os.path.abspath(param))
|
||||
|
||||
def set_params(tab: list):
|
||||
'''Initialize params from CLI arguments.'''
|
||||
for option in options:
|
||||
for option_name in options[option]:
|
||||
if option_name in tab:
|
||||
params[option] = True
|
||||
break
|
||||
|
||||
def print_help(name):
|
||||
'''Prints some help message.'''
|
||||
print(f'''SYNOPSIS
|
||||
{name} [OPTIONS] [PATHS...]
|
||||
DESCRIPTION
|
||||
{', '.join(options["help"])}
|
||||
prints this help message
|
||||
{', '.join(options["recursive"])}
|
||||
run on all subfolders of paths given
|
||||
{', '.join(options["mods"])}
|
||||
run on locally installed modules
|
||||
{', '.join(options["verbose"])}
|
||||
add output information
|
||||
''')
|
||||
|
||||
|
||||
def main():
|
||||
'''Main function'''
|
||||
set_params(_argv)
|
||||
set_params_folders(_argv)
|
||||
if params["help"]:
|
||||
print_help(_argv[0])
|
||||
elif params["recursive"] and params["mods"]:
|
||||
print("Option --installed-mods is incompatible with --recursive")
|
||||
else:
|
||||
# Add recursivity message
|
||||
print("Running ", end='')
|
||||
if params["recursive"]:
|
||||
print("recursively ", end='')
|
||||
# Running
|
||||
if params["mods"]:
|
||||
print(f"on all locally installed modules in {os.path.abspath('~/.minetest/mods/')}")
|
||||
run_all_subfolders("~/.minetest/mods")
|
||||
elif len(params["folders"]) >= 2:
|
||||
print("on folder list:", params["folders"])
|
||||
for f in params["folders"]:
|
||||
if params["recursive"]:
|
||||
run_all_subfolders(f)
|
||||
else:
|
||||
update_folder(f)
|
||||
elif len(params["folders"]) == 1:
|
||||
print("on folder", params["folders"][0])
|
||||
if params["recursive"]:
|
||||
run_all_subfolders(params["folders"][0])
|
||||
else:
|
||||
update_folder(params["folders"][0])
|
||||
else:
|
||||
print("on folder", os.path.abspath("./"))
|
||||
if params["recursive"]:
|
||||
run_all_subfolders(os.path.abspath("./"))
|
||||
else:
|
||||
update_folder(os.path.abspath("./"))
|
||||
|
||||
#group 2 will be the string, groups 1 and 3 will be the delimiters (" or ')
|
||||
#See https://stackoverflow.com/questions/46967465/regex-match-text-in-either-single-or-double-quote
|
||||
pattern_lua = re.compile(r'[\.=^\t,{\(\s]N?S\(\s*(["\'])((?:\\\1|(?:(?!\1)).)*)(\1)[\s,\)]', re.DOTALL)
|
||||
pattern_lua_bracketed = re.compile(r'[\.=^\t,{\(\s]N?S\(\s*\[\[(.*?)\]\][\s,\)]', re.DOTALL)
|
||||
|
||||
# Handles "concatenation" .. " of strings"
|
||||
pattern_concat = re.compile(r'["\'][\s]*\.\.[\s]*["\']', re.DOTALL)
|
||||
|
||||
pattern_tr = re.compile(r'(.+?[^@])=(.*)')
|
||||
pattern_name = re.compile(r'^name[ ]*=[ ]*([^ \n]*)')
|
||||
pattern_tr_filename = re.compile(r'\.tr$')
|
||||
pattern_po_language_code = re.compile(r'(.*)\.po$')
|
||||
|
||||
#attempt to read the mod's name from the mod.conf file. Returns None on failure
|
||||
def get_modname(folder):
|
||||
try:
|
||||
with open(os.path.join(folder, "mod.conf"), "r", encoding='utf-8') as mod_conf:
|
||||
for line in mod_conf:
|
||||
match = pattern_name.match(line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return None
|
||||
|
||||
#If there are already .tr files in /locale, returns a list of their names
|
||||
def get_existing_tr_files(folder):
|
||||
out = []
|
||||
for root, dirs, files in os.walk(os.path.join(folder, 'locale/')):
|
||||
for name in files:
|
||||
if pattern_tr_filename.search(name):
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
# A series of search and replaces that massage a .po file's contents into
|
||||
# a .tr file's equivalent
|
||||
def process_po_file(text):
|
||||
# The first three items are for unused matches
|
||||
text = re.sub(r'#~ msgid "', "", text)
|
||||
text = re.sub(r'"\n#~ msgstr ""\n"', "=", text)
|
||||
text = re.sub(r'"\n#~ msgstr "', "=", text)
|
||||
# comment lines
|
||||
text = re.sub(r'#.*\n', "", text)
|
||||
# converting msg pairs into "=" pairs
|
||||
text = re.sub(r'msgid "', "", text)
|
||||
text = re.sub(r'"\nmsgstr ""\n"', "=", text)
|
||||
text = re.sub(r'"\nmsgstr "', "=", text)
|
||||
# various line breaks and escape codes
|
||||
text = re.sub(r'"\n"', "", text)
|
||||
text = re.sub(r'"\n', "\n", text)
|
||||
text = re.sub(r'\\"', '"', text)
|
||||
text = re.sub(r'\\n', '@n', text)
|
||||
# remove header text
|
||||
text = re.sub(r'=Project-Id-Version:.*\n', "", text)
|
||||
# remove double-spaced lines
|
||||
text = re.sub(r'\n\n', '\n', text)
|
||||
return text
|
||||
|
||||
# Go through existing .po files and, if a .tr file for that language
|
||||
# *doesn't* exist, convert it and create it.
|
||||
# The .tr file that results will subsequently be reprocessed so
|
||||
# any "no longer used" strings will be preserved.
|
||||
# Note that "fuzzy" tags will be lost in this process.
|
||||
def process_po_files(folder, modname):
|
||||
for root, dirs, files in os.walk(os.path.join(folder, 'locale/')):
|
||||
for name in files:
|
||||
code_match = pattern_po_language_code.match(name)
|
||||
if code_match == None:
|
||||
continue
|
||||
language_code = code_match.group(1)
|
||||
tr_name = modname + "." + language_code + ".tr"
|
||||
tr_file = os.path.join(root, tr_name)
|
||||
if os.path.exists(tr_file):
|
||||
if params["verbose"]:
|
||||
print(f"{tr_name} already exists, ignoring {name}")
|
||||
continue
|
||||
fname = os.path.join(root, name)
|
||||
with open(fname, "r", encoding='utf-8') as po_file:
|
||||
if params["verbose"]:
|
||||
print(f"Importing translations from {name}")
|
||||
text = process_po_file(po_file.read())
|
||||
with open(tr_file, "wt", encoding='utf-8') as tr_out:
|
||||
tr_out.write(text)
|
||||
|
||||
# from https://stackoverflow.com/questions/600268/mkdir-p-functionality-in-python/600612#600612
|
||||
# Creates a directory if it doesn't exist, silently does
|
||||
# nothing if it already exists
|
||||
def mkdir_p(path):
|
||||
try:
|
||||
os.makedirs(path)
|
||||
except OSError as exc: # Python >2.5
|
||||
if exc.errno == errno.EEXIST and os.path.isdir(path):
|
||||
pass
|
||||
else: raise
|
||||
|
||||
# Converts the template dictionary to a text to be written as a file
|
||||
# dKeyStrings is a dictionary of localized string to source file sets
|
||||
# dOld is a dictionary of existing translations and comments from
|
||||
# the previous version of this text
|
||||
def strings_to_text(dkeyStrings, dOld, mod_name):
|
||||
lOut = [f"# textdomain: {mod_name}\n"]
|
||||
|
||||
dGroupedBySource = {}
|
||||
|
||||
for key in dkeyStrings:
|
||||
sourceList = list(dkeyStrings[key])
|
||||
sourceList.sort()
|
||||
sourceString = "\n".join(sourceList)
|
||||
listForSource = dGroupedBySource.get(sourceString, [])
|
||||
listForSource.append(key)
|
||||
dGroupedBySource[sourceString] = listForSource
|
||||
|
||||
lSourceKeys = list(dGroupedBySource.keys())
|
||||
lSourceKeys.sort()
|
||||
for source in lSourceKeys:
|
||||
localizedStrings = dGroupedBySource[source]
|
||||
localizedStrings.sort()
|
||||
lOut.append("")
|
||||
lOut.append(source)
|
||||
lOut.append("")
|
||||
for localizedString in localizedStrings:
|
||||
val = dOld.get(localizedString, {})
|
||||
translation = val.get("translation", "")
|
||||
comment = val.get("comment")
|
||||
if len(localizedString) > doublespace_threshold and not lOut[-1] == "":
|
||||
lOut.append("")
|
||||
if comment != None:
|
||||
lOut.append(comment)
|
||||
lOut.append(f"{localizedString}={translation}")
|
||||
if len(localizedString) > doublespace_threshold:
|
||||
lOut.append("")
|
||||
|
||||
|
||||
unusedExist = False
|
||||
for key in dOld:
|
||||
if key not in dkeyStrings:
|
||||
val = dOld[key]
|
||||
translation = val.get("translation")
|
||||
comment = val.get("comment")
|
||||
# only keep an unused translation if there was translated
|
||||
# text or a comment associated with it
|
||||
if translation != None and (translation != "" or comment):
|
||||
if not unusedExist:
|
||||
unusedExist = True
|
||||
lOut.append("\n\n##### not used anymore #####\n")
|
||||
if len(key) > doublespace_threshold and not lOut[-1] == "":
|
||||
lOut.append("")
|
||||
if comment != None:
|
||||
lOut.append(comment)
|
||||
lOut.append(f"{key}={translation}")
|
||||
if len(key) > doublespace_threshold:
|
||||
lOut.append("")
|
||||
return "\n".join(lOut) + '\n'
|
||||
|
||||
# Writes a template.txt file
|
||||
# dkeyStrings is the dictionary returned by generate_template
|
||||
def write_template(templ_file, dkeyStrings, mod_name):
|
||||
# read existing template file to preserve comments
|
||||
existing_template = import_tr_file(templ_file)
|
||||
|
||||
text = strings_to_text(dkeyStrings, existing_template[0], mod_name)
|
||||
mkdir_p(os.path.dirname(templ_file))
|
||||
with open(templ_file, "wt", encoding='utf-8') as template_file:
|
||||
template_file.write(text)
|
||||
|
||||
|
||||
# Gets all translatable strings from a lua file
|
||||
def read_lua_file_strings(lua_file):
|
||||
lOut = []
|
||||
with open(lua_file, encoding='utf-8') as text_file:
|
||||
text = text_file.read()
|
||||
#TODO remove comments here
|
||||
|
||||
text = re.sub(pattern_concat, "", text)
|
||||
|
||||
strings = []
|
||||
for s in pattern_lua.findall(text):
|
||||
strings.append(s[1])
|
||||
for s in pattern_lua_bracketed.findall(text):
|
||||
strings.append(s)
|
||||
|
||||
for s in strings:
|
||||
s = re.sub(r'"\.\.\s+"', "", s)
|
||||
s = re.sub("@[^@=0-9]", "@@", s)
|
||||
s = s.replace('\\"', '"')
|
||||
s = s.replace("\\'", "'")
|
||||
s = s.replace("\n", "@n")
|
||||
s = s.replace("\\n", "@n")
|
||||
s = s.replace("=", "@=")
|
||||
lOut.append(s)
|
||||
return lOut
|
||||
|
||||
# Gets strings from an existing translation file
|
||||
# returns both a dictionary of translations
|
||||
# and the full original source text so that the new text
|
||||
# can be compared to it for changes.
|
||||
def import_tr_file(tr_file):
|
||||
dOut = {}
|
||||
text = None
|
||||
if os.path.exists(tr_file):
|
||||
with open(tr_file, "r", encoding='utf-8') as existing_file :
|
||||
# save the full text to allow for comparison
|
||||
# of the old version with the new output
|
||||
text = existing_file.read()
|
||||
existing_file.seek(0)
|
||||
# a running record of the current comment block
|
||||
# we're inside, to allow preceeding multi-line comments
|
||||
# to be retained for a translation line
|
||||
latest_comment_block = None
|
||||
for line in existing_file.readlines():
|
||||
line = line.rstrip('\n')
|
||||
if line[:3] == "###":
|
||||
# Reset comment block if we hit a header
|
||||
latest_comment_block = None
|
||||
continue
|
||||
if line[:1] == "#":
|
||||
# Save the comment we're inside
|
||||
if not latest_comment_block:
|
||||
latest_comment_block = line
|
||||
else:
|
||||
latest_comment_block = latest_comment_block + "\n" + line
|
||||
continue
|
||||
match = pattern_tr.match(line)
|
||||
if match:
|
||||
# this line is a translated line
|
||||
outval = {}
|
||||
outval["translation"] = match.group(2)
|
||||
if latest_comment_block:
|
||||
# if there was a comment, record that.
|
||||
outval["comment"] = latest_comment_block
|
||||
latest_comment_block = None
|
||||
dOut[match.group(1)] = outval
|
||||
return (dOut, text)
|
||||
|
||||
# Walks all lua files in the mod folder, collects translatable strings,
|
||||
# and writes it to a template.txt file
|
||||
# Returns a dictionary of localized strings to source file sets
|
||||
# that can be used with the strings_to_text function.
|
||||
def generate_template(folder, mod_name):
|
||||
dOut = {}
|
||||
for root, dirs, files in os.walk(folder):
|
||||
for name in files:
|
||||
if fnmatch.fnmatch(name, "*.lua"):
|
||||
fname = os.path.join(root, name)
|
||||
found = read_lua_file_strings(fname)
|
||||
if params["verbose"]:
|
||||
print(f"{fname}: {str(len(found))} translatable strings")
|
||||
|
||||
for s in found:
|
||||
sources = dOut.get(s, set())
|
||||
sources.add(f"### {os.path.basename(fname)} ###")
|
||||
dOut[s] = sources
|
||||
|
||||
if len(dOut) == 0:
|
||||
return None
|
||||
templ_file = os.path.join(folder, "locale/template.txt")
|
||||
write_template(templ_file, dOut, mod_name)
|
||||
return dOut
|
||||
|
||||
# Updates an existing .tr file, copying the old one to a ".old" file
|
||||
# if any changes have happened
|
||||
# dNew is the data used to generate the template, it has all the
|
||||
# currently-existing localized strings
|
||||
def update_tr_file(dNew, mod_name, tr_file):
|
||||
if params["verbose"]:
|
||||
print(f"updating {tr_file}")
|
||||
|
||||
tr_import = import_tr_file(tr_file)
|
||||
dOld = tr_import[0]
|
||||
textOld = tr_import[1]
|
||||
|
||||
textNew = strings_to_text(dNew, dOld, mod_name)
|
||||
|
||||
if textOld and textOld != textNew:
|
||||
print(f"{tr_file} has changed.")
|
||||
shutil.copyfile(tr_file, f"{tr_file}.old")
|
||||
|
||||
with open(tr_file, "w", encoding='utf-8') as new_tr_file:
|
||||
new_tr_file.write(textNew)
|
||||
|
||||
# Updates translation files for the mod in the given folder
|
||||
def update_mod(folder):
|
||||
modname = get_modname(folder)
|
||||
if modname is not None:
|
||||
process_po_files(folder, modname)
|
||||
print(f"Updating translations for {modname}")
|
||||
data = generate_template(folder, modname)
|
||||
if data == None:
|
||||
print(f"No translatable strings found in {modname}")
|
||||
else:
|
||||
for tr_file in get_existing_tr_files(folder):
|
||||
update_tr_file(data, modname, os.path.join(folder, "locale/", tr_file))
|
||||
else:
|
||||
print("Unable to find modname in folder " + folder)
|
||||
|
||||
# Determines if the folder being pointed to is a mod or a mod pack
|
||||
# and then runs update_mod accordingly
|
||||
def update_folder(folder):
|
||||
is_modpack = os.path.exists(os.path.join(folder, "modpack.txt")) or os.path.exists(os.path.join(folder, "modpack.conf"))
|
||||
if is_modpack:
|
||||
subfolders = [f.path for f in os.scandir(folder) if f.is_dir()]
|
||||
for subfolder in subfolders:
|
||||
update_mod(subfolder + "/")
|
||||
else:
|
||||
update_mod(folder)
|
||||
print("Done.")
|
||||
|
||||
def run_all_subfolders(folder):
|
||||
for modfolder in [f.path for f in os.scandir(folder) if f.is_dir()]:
|
||||
update_folder(modfolder + "/")
|
||||
|
||||
|
||||
main()
|
427
bulletin_boards/init.lua
Normal file
@ -0,0 +1,427 @@
|
||||
-- TODO:
|
||||
-- There's potential race conditions in here if two players have the board open
|
||||
-- and a culling happens or they otherwise diddle around with it. For now just
|
||||
-- make sure it doesn't crash
|
||||
|
||||
local S = minetest.get_translator(minetest.get_current_modname())
|
||||
|
||||
local bulletin_max = 7*8
|
||||
|
||||
local culling_interval = 86400 -- one day in seconds
|
||||
local culling_min = bulletin_max - 12 -- won't cull if there are this many or fewer bulletins
|
||||
|
||||
local bulletin_boards = {}
|
||||
bulletin_boards.player_state = {}
|
||||
bulletin_boards.board_def = {}
|
||||
|
||||
local path = minetest.get_worldpath() .. "/bulletin_boards.lua"
|
||||
local f, e = loadfile(path);
|
||||
if f then
|
||||
bulletin_boards.global_boards = f()
|
||||
else
|
||||
bulletin_boards.global_boards = {}
|
||||
end
|
||||
|
||||
local function save_boards()
|
||||
local file, e = io.open(path, "w");
|
||||
if not file then
|
||||
return error(e);
|
||||
end
|
||||
file:write(minetest.serialize(bulletin_boards.global_boards))
|
||||
file:close()
|
||||
end
|
||||
|
||||
local max_text_size = 5000 -- half a book
|
||||
local max_title_size = 60
|
||||
local short_title_size = 12
|
||||
|
||||
-- gets the bulletins currently on a board
|
||||
-- and other persisted data
|
||||
local function get_board(name)
|
||||
local board = bulletin_boards.global_boards[name]
|
||||
if board then
|
||||
return board
|
||||
end
|
||||
board = {}
|
||||
board.last_culled = minetest.get_gametime()
|
||||
bulletin_boards.global_boards[name] = board
|
||||
return board
|
||||
end
|
||||
|
||||
-- for incrementing through the bulletins on a board
|
||||
local function find_next(board, start_index)
|
||||
local index = start_index + 1
|
||||
while index ~= start_index do
|
||||
if board[index] then
|
||||
return index
|
||||
end
|
||||
index = index + 1
|
||||
if index > bulletin_max then
|
||||
index = 1
|
||||
end
|
||||
end
|
||||
return index
|
||||
end
|
||||
local function find_prev(board, start_index)
|
||||
local index = start_index - 1
|
||||
while index ~= start_index do
|
||||
if board[index] then
|
||||
return index
|
||||
end
|
||||
index = index - 1
|
||||
if index < 1 then
|
||||
index = bulletin_max
|
||||
end
|
||||
end
|
||||
return index
|
||||
end
|
||||
|
||||
-- Groups bulletins by count-per-player, then picks the oldest bulletin from the group with the highest count.
|
||||
|
||||
-- eg, if A has 1 bulletin, B has 2 bulletins, and C has 2 bulletins, then this will pick the oldest
|
||||
-- bulletin from (B and C)'s bulletins. Returns index and timestamp, or nil if there's nothing.
|
||||
local function find_most_cullable(board_name)
|
||||
local board = get_board(board_name)
|
||||
local player_count = {}
|
||||
local max_count = 0
|
||||
local total = 0
|
||||
for i = 1, bulletin_max do
|
||||
local bulletin = board[i]
|
||||
if bulletin then
|
||||
total = total + 1
|
||||
local player_name = bulletin.owner
|
||||
local count = (player_count[player_name] or 0) + 1
|
||||
max_count = math.max(count, max_count)
|
||||
player_count[player_name] = count
|
||||
end
|
||||
end
|
||||
|
||||
if total <= culling_min then
|
||||
return
|
||||
end
|
||||
|
||||
local max_players = {}
|
||||
for player_name, count in pairs(player_count) do
|
||||
if count == max_count then
|
||||
max_players[player_name] = true
|
||||
end
|
||||
end
|
||||
|
||||
local most_cullable_index
|
||||
local most_cullable_timestamp
|
||||
for i = 1, bulletin_max do
|
||||
local bulletin = board[i]
|
||||
if bulletin and max_players[bulletin.owner] then
|
||||
if bulletin.timestamp <= (most_cullable_timestamp or bulletin.timestamp) then
|
||||
most_cullable_timestamp = bulletin.timestamp
|
||||
most_cullable_index = i
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return most_cullable_index, most_cullable_timestamp
|
||||
end
|
||||
|
||||
-- safe way to get the description string of an item, in case it's not registered
|
||||
local function get_item_desc(stack)
|
||||
local stack_def = stack:get_definition()
|
||||
if stack_def then
|
||||
return stack_def.description
|
||||
end
|
||||
return stack:get_name()
|
||||
end
|
||||
|
||||
-- shows the base board to a player
|
||||
local function show_board(player_name, board_name)
|
||||
local formspec = {}
|
||||
local board = get_board(board_name)
|
||||
local current_time = minetest.get_gametime()
|
||||
|
||||
local intervals = (current_time - board.last_culled)/culling_interval
|
||||
local cull_count, remaining_cull_time = math.modf(intervals)
|
||||
while cull_count > 0 do
|
||||
local cull_index = find_most_cullable(board_name)
|
||||
if cull_index then
|
||||
board[cull_index] = nil
|
||||
cull_count = cull_count - 1
|
||||
else
|
||||
cull_count = 0
|
||||
end
|
||||
end
|
||||
board.last_culled = current_time - math.floor(culling_interval * remaining_cull_time)
|
||||
|
||||
local def = bulletin_boards.board_def[board_name]
|
||||
local desc = minetest.formspec_escape(def.desc)
|
||||
local tip
|
||||
if def.cost then
|
||||
local stack = ItemStack(def.cost)
|
||||
tip = S("Post your bulletin here for the cost of @1 @2", stack:get_count(), get_item_desc(stack))
|
||||
desc = desc .. S(", Cost: @1 @2", stack:get_count(), get_item_desc(stack))
|
||||
else
|
||||
tip = S("Post your bulletin here")
|
||||
end
|
||||
|
||||
formspec[#formspec+1] = "size[8,8.5]"
|
||||
.. "container[0,0]"
|
||||
.. "label[0.0,-0.25;"..desc.."]"
|
||||
.. "container_end[]"
|
||||
.. "container[0,0.5]"
|
||||
local i = 0
|
||||
for y = 0, 6 do
|
||||
for x = 0, 7 do
|
||||
i = i + 1
|
||||
local bulletin = board[i] or {}
|
||||
local short_title = bulletin.title or ""
|
||||
--Don't bother triming the title if the trailing dots would make it longer
|
||||
if #short_title > short_title_size + 3 then
|
||||
short_title = short_title:sub(1, short_title_size) .. "..."
|
||||
end
|
||||
local img = bulletin.icon or ""
|
||||
|
||||
formspec[#formspec+1] =
|
||||
"image_button["..x..",".. y*1.2 ..";1,1;"..img..";button_"..i..";]"
|
||||
.."label["..x..","..y*1.2-0.35 ..";"..minetest.formspec_escape(short_title).."]"
|
||||
if bulletin.title and bulletin.owner and bulletin.timestamp then
|
||||
local days_ago = math.floor((current_time-bulletin.timestamp)/86400)
|
||||
formspec[#formspec+1] = "tooltip[button_"..i..";"
|
||||
..S("@1\nPosted by @2\n@3 days ago", minetest.formspec_escape(bulletin.title), bulletin.owner, days_ago).."]"
|
||||
else
|
||||
formspec[#formspec+1] = "tooltip[button_"..i..";"..tip.."]"
|
||||
end
|
||||
end
|
||||
end
|
||||
formspec[#formspec+1] = "container_end[]"
|
||||
|
||||
bulletin_boards.player_state[player_name] = {board=board_name}
|
||||
minetest.show_formspec(player_name, "bulletin_boards:board", table.concat(formspec))
|
||||
end
|
||||
|
||||
-- shows a specific bulletin on a board
|
||||
local function show_bulletin(player, board_name, index)
|
||||
local board = get_board(board_name)
|
||||
local def = bulletin_boards.board_def[board_name]
|
||||
local icons = def.icons
|
||||
local bulletin = board[index] or {}
|
||||
local player_name = player:get_player_name()
|
||||
bulletin_boards.player_state[player_name] = {board=board_name, index=index}
|
||||
|
||||
local tip
|
||||
local has_cost
|
||||
if def.cost then
|
||||
local stack = ItemStack(def.cost)
|
||||
local player_inventory = minetest.get_inventory({type="player", name=player_name})
|
||||
tip = S("Post bulletin with this icon at the cost of @1 @2", stack:get_count(), get_item_desc(stack))
|
||||
has_cost = player_inventory:contains_item("main", stack)
|
||||
else
|
||||
tip = S("Post bulletin with this icon")
|
||||
has_cost = true
|
||||
end
|
||||
|
||||
local admin = minetest.check_player_privs(player, "server")
|
||||
|
||||
local formspec = {"size[8,8]"
|
||||
.."button[0.2,0;1,1;prev;"..S("Prev").."]"
|
||||
.."button[6.65,0;1,1;next;"..S("Next").."]"}
|
||||
local esc = minetest.formspec_escape
|
||||
if ((bulletin.owner == nil or bulletin.owner == player_name) and has_cost) or admin then
|
||||
formspec[#formspec+1] =
|
||||
"field[1.5,0.75;5.5,0;title;"..S("Title:")..";"..esc(bulletin.title or "").."]"
|
||||
.."textarea[0.5,1.15;7.5,7;text;"..S("Contents:")..";"..esc(bulletin.text or "").."]"
|
||||
.."label[0.3,7;"..S("Post:").."]"
|
||||
for i, icon in ipairs(icons) do
|
||||
formspec[#formspec+1] = "image_button[".. i*0.75-0.5 ..",7.35;1,1;"..icon..";save_"..i..";]"
|
||||
.."tooltip[save_"..i..";"..tip.."]"
|
||||
end
|
||||
formspec[#formspec+1] = "image_button[".. (#icons+1)*0.75-0.25 ..",7.35;1,1;bulletin_boards_delete.png;delete;]"
|
||||
.."tooltip[delete;"..S("Delete this bulletin").."]"
|
||||
.."label["..(#icons+1)*0.75-0.25 ..",7;"..S("Delete:").."]"
|
||||
elseif bulletin.owner then
|
||||
formspec[#formspec+1] =
|
||||
"label[1.4,0.5;"..S("Posted by @1", bulletin.owner).."]"
|
||||
.."tablecolumns[color;text]"
|
||||
.."tableoptions[background=#00000000;highlight=#00000000;border=false]"
|
||||
.."table[1.35,0.25;5,0.5;title;#FFFF00,"..esc(bulletin.title or "").."]"
|
||||
.."textarea[0.5,1.5;7.5,7;;"..esc(bulletin.text or "")..";]"
|
||||
.."button[2.5,7.5;3,1;back;" .. S("Back to Board") .. "]"
|
||||
if bulletin.owner == player_name then
|
||||
formspec[#formspec+1] = "image_button[".. (#icons+1)*0.75-0.25 ..",7.35;1,1;bulletin_boards_delete.png;delete;]"
|
||||
.."tooltip[delete;"..S("Delete this bulletin").."]"
|
||||
.."label["..(#icons+1)*0.75-0.25 ..",7;"..S("Delete:").."]"
|
||||
end
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
minetest.show_formspec(player_name, "bulletin_boards:bulletin", table.concat(formspec))
|
||||
end
|
||||
|
||||
-- interpret clicks on the base board
|
||||
minetest.register_on_player_receive_fields(function(player, formname, fields)
|
||||
if formname ~= "bulletin_boards:board" then return end
|
||||
local player_name = player:get_player_name()
|
||||
for field, state in pairs(fields) do
|
||||
if field:sub(1, #"button_") == "button_" then
|
||||
local i = tonumber(field:sub(#"button_"+1))
|
||||
local state = bulletin_boards.player_state[player_name]
|
||||
if state then
|
||||
show_bulletin(player, state.board, i)
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
-- interpret clicks on the bulletin
|
||||
minetest.register_on_player_receive_fields(function(player, formname, fields)
|
||||
if formname ~= "bulletin_boards:bulletin" then return end
|
||||
local player_name = player:get_player_name()
|
||||
local state = bulletin_boards.player_state[player_name]
|
||||
if not state then return end
|
||||
local board = get_board(state.board)
|
||||
local def = bulletin_boards.board_def[state.board]
|
||||
if not board then return end
|
||||
|
||||
-- no security needed on these actions
|
||||
if fields.back then
|
||||
bulletin_boards.player_state[player_name] = nil
|
||||
show_board(player_name, state.board)
|
||||
end
|
||||
|
||||
if fields.prev then
|
||||
local next_index = find_prev(board, state.index)
|
||||
show_bulletin(player, state.board, next_index)
|
||||
return
|
||||
end
|
||||
if fields.next then
|
||||
local next_index = find_next(board, state.index)
|
||||
show_bulletin(player, state.board, next_index)
|
||||
return
|
||||
end
|
||||
|
||||
if fields.quit then
|
||||
minetest.after(0.1, show_board, player_name, state.board)
|
||||
end
|
||||
|
||||
-- check if the player's allowed to do the stuff after this
|
||||
local admin = minetest.check_player_privs(player, "server")
|
||||
local current_bulletin = board[state.index]
|
||||
if not admin and (current_bulletin and current_bulletin.owner ~= player_name) then
|
||||
-- someone's done something funny. Don't be accusatory, though - could be a race condition
|
||||
return
|
||||
end
|
||||
|
||||
if fields.delete then
|
||||
board[state.index] = nil
|
||||
fields.title = ""
|
||||
save_boards()
|
||||
end
|
||||
|
||||
local player_inventory = minetest.get_inventory({type="player", name=player_name})
|
||||
local has_cost = true
|
||||
if def.cost then
|
||||
has_cost = player_inventory:contains_item("main", def.cost)
|
||||
end
|
||||
|
||||
if fields.text ~= "" and (has_cost or admin) then
|
||||
for field, _ in pairs(fields) do
|
||||
if field:sub(1, #"save_") == "save_" then
|
||||
local i = tonumber(field:sub(#"save_"+1))
|
||||
local bulletin = {}
|
||||
bulletin.owner = player_name
|
||||
bulletin.title = fields.title:sub(1, max_title_size)
|
||||
bulletin.text = fields.text:sub(1, max_text_size)
|
||||
bulletin.icon = def.icons[i]
|
||||
bulletin.timestamp = minetest.get_gametime()
|
||||
board[state.index] = bulletin
|
||||
if not admin and def.cost then
|
||||
player_inventory:remove_item("main", def.cost)
|
||||
end
|
||||
save_boards()
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
bulletin_boards.player_state[player_name] = nil
|
||||
show_board(player_name, state.board)
|
||||
end)
|
||||
|
||||
-- default icon set
|
||||
local base_icons = {
|
||||
"bulletin_boards_document_comment_above.png",
|
||||
"bulletin_boards_document_back.png",
|
||||
"bulletin_boards_document_next.png",
|
||||
"bulletin_boards_document_image.png",
|
||||
"bulletin_boards_document_signature.png",
|
||||
"bulletin_boards_to_do_list.png",
|
||||
"bulletin_boards_documents_email.png",
|
||||
"bulletin_boards_receipt_invoice.png",
|
||||
}
|
||||
|
||||
-- generates a random jumble of icons to superimpose on a bulletin board texture
|
||||
-- rez is the "working" canvas size. 32-pixel icons get scattered on that canvas
|
||||
-- before it is scaled down to 16 pixels
|
||||
local function generate_random_board(rez, count, icons)
|
||||
icons = icons or base_icons
|
||||
local tex = {"([combine:"..rez.."x"..rez}
|
||||
for i = 1, count do
|
||||
tex[#tex+1] = ":"..math.random(1,rez-32)..","..math.random(1,rez-32)
|
||||
.."="..icons[math.random(1,#icons)]
|
||||
end
|
||||
tex[#tex+1] = "^[resize:16x16)"
|
||||
return table.concat(tex)
|
||||
end
|
||||
|
||||
local function register_board(board_name, board_def)
|
||||
bulletin_boards.board_def[board_name] = board_def
|
||||
local background = board_def.background or "bulletin_boards_corkboard.png"
|
||||
local foreground = board_def.foreground or "bulletin_boards_frame.png"
|
||||
local tile = background.."^"..generate_random_board(98, 7, board_def.icons).."^"..foreground
|
||||
local bulletin_board_def = {
|
||||
description = board_def.desc,
|
||||
groups = {choppy=1},
|
||||
tiles = {tile},
|
||||
inventory_image = tile,
|
||||
paramtype = "light",
|
||||
paramtype2 = "wallmounted",
|
||||
sunlight_propagates = true,
|
||||
drawtype = "nodebox",
|
||||
node_box = {
|
||||
type = "wallmounted",
|
||||
wall_top = {-0.5, 0.4375, -0.5, 0.5, 0.5, 0.5},
|
||||
wall_bottom = {-0.5, -0.5, -0.5, 0.5, -0.4375, 0.5},
|
||||
wall_side = {-0.5, -0.5, -0.5, -0.4375, 0.5, 0.5},
|
||||
},
|
||||
|
||||
on_rightclick = function(pos, node, clicker, itemstack, pointed_thing)
|
||||
local player_name = clicker:get_player_name()
|
||||
show_board(player_name, board_name)
|
||||
end,
|
||||
|
||||
on_construct = function(pos)
|
||||
local meta = minetest.get_meta(pos)
|
||||
meta:set_string("infotext", board_def.desc or "")
|
||||
end,
|
||||
}
|
||||
|
||||
minetest.register_node(board_name, bulletin_board_def)
|
||||
end
|
||||
|
||||
if minetest.get_modpath("default") then
|
||||
|
||||
register_board("bulletin_boards:bulletin_board_basic", {
|
||||
desc = S("Public Bulletin Board"),
|
||||
cost = "default:paper",
|
||||
icons = base_icons,
|
||||
})
|
||||
--[[
|
||||
minetest.register_craft({
|
||||
output = "bulletin_boards:bulletin_board_basic",
|
||||
recipe = {
|
||||
{'group:wood', 'group:wood', 'group:wood'},
|
||||
{'group:wood', 'default:paper', 'group:wood'},
|
||||
{'group:wood', 'group:wood', 'group:wood'},
|
||||
},
|
||||
})
|
||||
]]--
|
||||
end
|
440
bulletin_boards/init.lua.ORIG
Normal file
@ -0,0 +1,440 @@
|
||||
-- TODO:
|
||||
-- There's potential race conditions in here if two players have the board open
|
||||
-- and a culling happens or they otherwise diddle around with it. For now just
|
||||
-- make sure it doesn't crash
|
||||
|
||||
local S = minetest.get_translator(minetest.get_current_modname())
|
||||
|
||||
local bulletin_max = 7*8
|
||||
|
||||
local culling_interval = 86400 -- one day in seconds
|
||||
local culling_min = bulletin_max - 12 -- won't cull if there are this many or fewer bulletins
|
||||
|
||||
local bulletin_boards = {}
|
||||
bulletin_boards.player_state = {}
|
||||
bulletin_boards.board_def = {}
|
||||
|
||||
local path = minetest.get_worldpath() .. "/bulletin_boards.lua"
|
||||
local f, e = loadfile(path);
|
||||
if f then
|
||||
bulletin_boards.global_boards = f()
|
||||
else
|
||||
bulletin_boards.global_boards = {}
|
||||
end
|
||||
|
||||
local function save_boards()
|
||||
local file, e = io.open(path, "w");
|
||||
if not file then
|
||||
return error(e);
|
||||
end
|
||||
file:write(minetest.serialize(bulletin_boards.global_boards))
|
||||
file:close()
|
||||
end
|
||||
|
||||
local max_text_size = 5000 -- half a book
|
||||
local max_title_size = 60
|
||||
local short_title_size = 12
|
||||
|
||||
-- gets the bulletins currently on a board
|
||||
-- and other persisted data
|
||||
local function get_board(name)
|
||||
local board = bulletin_boards.global_boards[name]
|
||||
if board then
|
||||
return board
|
||||
end
|
||||
board = {}
|
||||
board.last_culled = minetest.get_gametime()
|
||||
bulletin_boards.global_boards[name] = board
|
||||
return board
|
||||
end
|
||||
|
||||
-- for incrementing through the bulletins on a board
|
||||
local function find_next(board, start_index)
|
||||
local index = start_index + 1
|
||||
while index ~= start_index do
|
||||
if board[index] then
|
||||
return index
|
||||
end
|
||||
index = index + 1
|
||||
if index > bulletin_max then
|
||||
index = 1
|
||||
end
|
||||
end
|
||||
return index
|
||||
end
|
||||
local function find_prev(board, start_index)
|
||||
local index = start_index - 1
|
||||
while index ~= start_index do
|
||||
if board[index] then
|
||||
return index
|
||||
end
|
||||
index = index - 1
|
||||
if index < 1 then
|
||||
index = bulletin_max
|
||||
end
|
||||
end
|
||||
return index
|
||||
end
|
||||
|
||||
-- Groups bulletins by count-per-player, then picks the oldest bulletin from the group with the highest count.
|
||||
|
||||
-- eg, if A has 1 bulletin, B has 2 bulletins, and C has 2 bulletins, then this will pick the oldest
|
||||
-- bulletin from (B and C)'s bulletins. Returns index and timestamp, or nil if there's nothing.
|
||||
local function find_most_cullable(board_name)
|
||||
local board = get_board(board_name)
|
||||
local player_count = {}
|
||||
local max_count = 0
|
||||
local total = 0
|
||||
for i = 1, bulletin_max do
|
||||
local bulletin = board[i]
|
||||
if bulletin then
|
||||
total = total + 1
|
||||
local player_name = bulletin.owner
|
||||
local count = (player_count[player_name] or 0) + 1
|
||||
max_count = math.max(count, max_count)
|
||||
player_count[player_name] = count
|
||||
end
|
||||
end
|
||||
|
||||
if total <= culling_min then
|
||||
return
|
||||
end
|
||||
|
||||
local max_players = {}
|
||||
for player_name, count in pairs(player_count) do
|
||||
if count == max_count then
|
||||
max_players[player_name] = true
|
||||
end
|
||||
end
|
||||
|
||||
local most_cullable_index
|
||||
local most_cullable_timestamp
|
||||
for i = 1, bulletin_max do
|
||||
local bulletin = board[i]
|
||||
if bulletin and max_players[bulletin.owner] then
|
||||
if bulletin.timestamp <= (most_cullable_timestamp or bulletin.timestamp) then
|
||||
most_cullable_timestamp = bulletin.timestamp
|
||||
most_cullable_index = i
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return most_cullable_index, most_cullable_timestamp
|
||||
end
|
||||
|
||||
-- safe way to get the description string of an item, in case it's not registered
|
||||
local function get_item_desc(stack)
|
||||
local stack_def = stack:get_definition()
|
||||
if stack_def then
|
||||
return stack_def.description
|
||||
end
|
||||
return stack:get_name()
|
||||
end
|
||||
|
||||
-- shows the base board to a player
|
||||
local function show_board(player_name, board_name)
|
||||
local formspec = {}
|
||||
local board = get_board(board_name)
|
||||
local current_time = minetest.get_gametime()
|
||||
|
||||
local intervals = (current_time - board.last_culled)/culling_interval
|
||||
local cull_count, remaining_cull_time = math.modf(intervals)
|
||||
while cull_count > 0 do
|
||||
local cull_index = find_most_cullable(board_name)
|
||||
if cull_index then
|
||||
board[cull_index] = nil
|
||||
cull_count = cull_count - 1
|
||||
else
|
||||
cull_count = 0
|
||||
end
|
||||
end
|
||||
board.last_culled = current_time - math.floor(culling_interval * remaining_cull_time)
|
||||
|
||||
local def = bulletin_boards.board_def[board_name]
|
||||
local desc = minetest.formspec_escape(def.desc)
|
||||
local tip
|
||||
if def.cost then
|
||||
local stack = ItemStack(def.cost)
|
||||
tip = S("Post your bulletin here for the cost of @1 @2", stack:get_count(), get_item_desc(stack))
|
||||
desc = desc .. S(", Cost: @1 @2", stack:get_count(), get_item_desc(stack))
|
||||
else
|
||||
tip = S("Post your bulletin here")
|
||||
end
|
||||
|
||||
formspec[#formspec+1] = "size[8,8.5]"
|
||||
.. "container[0,0]"
|
||||
.. "label[0.0,-0.25;"..desc.."]"
|
||||
.. "container_end[]"
|
||||
.. "container[0,0.5]"
|
||||
local i = 0
|
||||
for y = 0, 6 do
|
||||
for x = 0, 7 do
|
||||
i = i + 1
|
||||
local bulletin = board[i] or {}
|
||||
local short_title = bulletin.title or ""
|
||||
--Don't bother triming the title if the trailing dots would make it longer
|
||||
if #short_title > short_title_size + 3 then
|
||||
short_title = short_title:sub(1, short_title_size) .. "..."
|
||||
end
|
||||
local img = bulletin.icon or ""
|
||||
|
||||
formspec[#formspec+1] =
|
||||
"image_button["..x..",".. y*1.2 ..";1,1;"..img..";button_"..i..";]"
|
||||
.."label["..x..","..y*1.2-0.35 ..";"..minetest.formspec_escape(short_title).."]"
|
||||
if bulletin.title and bulletin.owner and bulletin.timestamp then
|
||||
local days_ago = math.floor((current_time-bulletin.timestamp)/86400)
|
||||
formspec[#formspec+1] = "tooltip[button_"..i..";"
|
||||
..S("@1\nPosted by @2\n@3 days ago", minetest.formspec_escape(bulletin.title), bulletin.owner, days_ago).."]"
|
||||
else
|
||||
formspec[#formspec+1] = "tooltip[button_"..i..";"..tip.."]"
|
||||
end
|
||||
end
|
||||
end
|
||||
formspec[#formspec+1] = "container_end[]"
|
||||
|
||||
bulletin_boards.player_state[player_name] = {board=board_name}
|
||||
minetest.show_formspec(player_name, "bulletin_boards:board", table.concat(formspec))
|
||||
end
|
||||
|
||||
-- shows a specific bulletin on a board
|
||||
local function show_bulletin(player, board_name, index)
|
||||
local board = get_board(board_name)
|
||||
local def = bulletin_boards.board_def[board_name]
|
||||
local icons = def.icons
|
||||
local bulletin = board[index] or {}
|
||||
local player_name = player:get_player_name()
|
||||
bulletin_boards.player_state[player_name] = {board=board_name, index=index}
|
||||
|
||||
local tip
|
||||
local has_cost
|
||||
if def.cost then
|
||||
local stack = ItemStack(def.cost)
|
||||
local player_inventory = minetest.get_inventory({type="player", name=player_name})
|
||||
tip = S("Post bulletin with this icon at the cost of @1 @2", stack:get_count(), get_item_desc(stack))
|
||||
has_cost = player_inventory:contains_item("main", stack)
|
||||
else
|
||||
tip = S("Post bulletin with this icon")
|
||||
has_cost = true
|
||||
end
|
||||
|
||||
local admin = minetest.check_player_privs(player, "server")
|
||||
|
||||
local formspec = {"size[8,8]"
|
||||
.."button[0.2,0;1,1;prev;"..S("Prev").."]"
|
||||
.."button[6.65,0;1,1;next;"..S("Next").."]"}
|
||||
local esc = minetest.formspec_escape
|
||||
if ((bulletin.owner == nil or bulletin.owner == player_name) and has_cost) or admin then
|
||||
formspec[#formspec+1] =
|
||||
"field[1.5,0.75;5.5,0;title;"..S("Title:")..";"..esc(bulletin.title or "").."]"
|
||||
.."textarea[0.5,1.15;7.5,7;text;"..S("Contents:")..";"..esc(bulletin.text or "").."]"
|
||||
.."label[0.3,7;"..S("Post:").."]"
|
||||
for i, icon in ipairs(icons) do
|
||||
formspec[#formspec+1] = "image_button[".. i*0.75-0.5 ..",7.35;1,1;"..icon..";save_"..i..";]"
|
||||
.."tooltip[save_"..i..";"..tip.."]"
|
||||
end
|
||||
formspec[#formspec+1] = "image_button[".. (#icons+1)*0.75-0.25 ..",7.35;1,1;bulletin_boards_delete.png;delete;]"
|
||||
.."tooltip[delete;"..S("Delete this bulletin").."]"
|
||||
.."label["..(#icons+1)*0.75-0.25 ..",7;"..S("Delete:").."]"
|
||||
elseif bulletin.owner then
|
||||
formspec[#formspec+1] =
|
||||
"label[1.4,0.5;"..S("Posted by @1", bulletin.owner).."]"
|
||||
.."tablecolumns[color;text]"
|
||||
.."tableoptions[background=#00000000;highlight=#00000000;border=false]"
|
||||
.."table[1.35,0.25;5,0.5;title;#FFFF00,"..esc(bulletin.title or "").."]"
|
||||
.."textarea[0.5,1.5;7.5,7;;"..esc(bulletin.text or "")..";]"
|
||||
.."button[2.5,7.5;3,1;back;" .. S("Back to Board") .. "]"
|
||||
if bulletin.owner == player_name then
|
||||
formspec[#formspec+1] = "image_button[".. (#icons+1)*0.75-0.25 ..",7.35;1,1;bulletin_boards_delete.png;delete;]"
|
||||
.."tooltip[delete;"..S("Delete this bulletin").."]"
|
||||
.."label["..(#icons+1)*0.75-0.25 ..",7;"..S("Delete:").."]"
|
||||
end
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
minetest.show_formspec(player_name, "bulletin_boards:bulletin", table.concat(formspec))
|
||||
end
|
||||
|
||||
-- interpret clicks on the base board
|
||||
minetest.register_on_player_receive_fields(function(player, formname, fields)
|
||||
if formname ~= "bulletin_boards:board" then return end
|
||||
local player_name = player:get_player_name()
|
||||
for field, state in pairs(fields) do
|
||||
if field:sub(1, #"button_") == "button_" then
|
||||
local i = tonumber(field:sub(#"button_"+1))
|
||||
local state = bulletin_boards.player_state[player_name]
|
||||
if state then
|
||||
show_bulletin(player, state.board, i)
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
-- interpret clicks on the bulletin
|
||||
minetest.register_on_player_receive_fields(function(player, formname, fields)
|
||||
if formname ~= "bulletin_boards:bulletin" then return end
|
||||
local player_name = player:get_player_name()
|
||||
local state = bulletin_boards.player_state[player_name]
|
||||
if not state then return end
|
||||
local board = get_board(state.board)
|
||||
local def = bulletin_boards.board_def[state.board]
|
||||
if not board then return end
|
||||
|
||||
-- no security needed on these actions
|
||||
if fields.back then
|
||||
bulletin_boards.player_state[player_name] = nil
|
||||
show_board(player_name, state.board)
|
||||
end
|
||||
|
||||
if fields.prev then
|
||||
local next_index = find_prev(board, state.index)
|
||||
show_bulletin(player, state.board, next_index)
|
||||
return
|
||||
end
|
||||
if fields.next then
|
||||
local next_index = find_next(board, state.index)
|
||||
show_bulletin(player, state.board, next_index)
|
||||
return
|
||||
end
|
||||
|
||||
if fields.quit then
|
||||
minetest.after(0.1, show_board, player_name, state.board)
|
||||
end
|
||||
|
||||
-- check if the player's allowed to do the stuff after this
|
||||
local admin = minetest.check_player_privs(player, "server")
|
||||
local current_bulletin = board[state.index]
|
||||
if not admin and (current_bulletin and current_bulletin.owner ~= player_name) then
|
||||
-- someone's done something funny. Don't be accusatory, though - could be a race condition
|
||||
return
|
||||
end
|
||||
|
||||
if fields.delete then
|
||||
board[state.index] = nil
|
||||
fields.title = ""
|
||||
save_boards()
|
||||
end
|
||||
|
||||
local player_inventory = minetest.get_inventory({type="player", name=player_name})
|
||||
local has_cost = true
|
||||
if def.cost then
|
||||
has_cost = player_inventory:contains_item("main", def.cost)
|
||||
end
|
||||
|
||||
if fields.text ~= "" and (has_cost or admin) then
|
||||
for field, _ in pairs(fields) do
|
||||
if field:sub(1, #"save_") == "save_" then
|
||||
local i = tonumber(field:sub(#"save_"+1))
|
||||
local bulletin = {}
|
||||
bulletin.owner = player_name
|
||||
bulletin.title = fields.title:sub(1, max_title_size)
|
||||
bulletin.text = fields.text:sub(1, max_text_size)
|
||||
bulletin.icon = def.icons[i]
|
||||
bulletin.timestamp = minetest.get_gametime()
|
||||
board[state.index] = bulletin
|
||||
if not admin and def.cost then
|
||||
player_inventory:remove_item("main", def.cost)
|
||||
end
|
||||
save_boards()
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
bulletin_boards.player_state[player_name] = nil
|
||||
show_board(player_name, state.board)
|
||||
end)
|
||||
|
||||
-- default icon set
|
||||
local base_icons = {
|
||||
"bulletin_boards_document_comment_above.png",
|
||||
"bulletin_boards_document_back.png",
|
||||
"bulletin_boards_document_next.png",
|
||||
"bulletin_boards_document_image.png",
|
||||
"bulletin_boards_document_signature.png",
|
||||
"bulletin_boards_to_do_list.png",
|
||||
"bulletin_boards_documents_email.png",
|
||||
"bulletin_boards_receipt_invoice.png",
|
||||
}
|
||||
|
||||
-- generates a random jumble of icons to superimpose on a bulletin board texture
|
||||
-- rez is the "working" canvas size. 32-pixel icons get scattered on that canvas
|
||||
-- before it is scaled down to 16 pixels
|
||||
local function generate_random_board(rez, count, icons)
|
||||
icons = icons or base_icons
|
||||
local tex = {"([combine:"..rez.."x"..rez}
|
||||
for i = 1, count do
|
||||
tex[#tex+1] = ":"..math.random(1,rez-32)..","..math.random(1,rez-32)
|
||||
.."="..icons[math.random(1,#icons)]
|
||||
end
|
||||
tex[#tex+1] = "^[resize:16x16)"
|
||||
return table.concat(tex)
|
||||
end
|
||||
|
||||
local function register_board(board_name, board_def)
|
||||
bulletin_boards.board_def[board_name] = board_def
|
||||
local background = board_def.background or "bulletin_boards_corkboard.png"
|
||||
local foreground = board_def.foreground or "bulletin_boards_frame.png"
|
||||
local tile = background.."^"..generate_random_board(98, 7, board_def.icons).."^"..foreground
|
||||
local bulletin_board_def = {
|
||||
description = board_def.desc,
|
||||
groups = {choppy=1},
|
||||
tiles = {tile},
|
||||
inventory_image = tile,
|
||||
paramtype = "light",
|
||||
paramtype2 = "wallmounted",
|
||||
sunlight_propagates = true,
|
||||
drawtype = "nodebox",
|
||||
node_box = {
|
||||
type = "wallmounted",
|
||||
wall_top = {-0.5, 0.4375, -0.5, 0.5, 0.5, 0.5},
|
||||
wall_bottom = {-0.5, -0.5, -0.5, 0.5, -0.4375, 0.5},
|
||||
wall_side = {-0.5, -0.5, -0.5, -0.4375, 0.5, 0.5},
|
||||
},
|
||||
|
||||
on_rightclick = function(pos, node, clicker, itemstack, pointed_thing)
|
||||
local player_name = clicker:get_player_name()
|
||||
show_board(player_name, board_name)
|
||||
end,
|
||||
|
||||
on_construct = function(pos)
|
||||
local meta = minetest.get_meta(pos)
|
||||
meta:set_string("infotext", board_def.desc or "")
|
||||
end,
|
||||
}
|
||||
|
||||
minetest.register_node(board_name, bulletin_board_def)
|
||||
end
|
||||
|
||||
if minetest.get_modpath("default") then
|
||||
|
||||
register_board("bulletin_boards:bulletin_board_basic", {
|
||||
desc = S("Public Bulletin Board"),
|
||||
cost = "default:paper",
|
||||
icons = base_icons,
|
||||
})
|
||||
minetest.register_craft({
|
||||
output = "bulletin_boards:bulletin_board_basic",
|
||||
recipe = {
|
||||
{'group:wood', 'group:wood', 'group:wood'},
|
||||
{'group:wood', 'default:paper', 'group:wood'},
|
||||
{'group:wood', 'group:wood', 'group:wood'},
|
||||
},
|
||||
})
|
||||
|
||||
register_board("bulletin_boards:bulletin_board_copper", {
|
||||
desc = S("Copper Board"),
|
||||
cost = "default:copper_ingot",
|
||||
foreground = "bulletin_boards_frame_copper.png",
|
||||
icons = base_icons,
|
||||
})
|
||||
minetest.register_craft({
|
||||
output = "bulletin_boards:bulletin_board_copper",
|
||||
recipe = {
|
||||
{"default:copper_ingot", "default:copper_ingot", "default:copper_ingot"},
|
||||
{"default:copper_ingot", 'default:paper', "default:copper_ingot"},
|
||||
{"default:copper_ingot", "default:copper_ingot", "default:copper_ingot"},
|
||||
},
|
||||
})
|
||||
end
|
39
bulletin_boards/locale/bulletin_boards.de.tr
Normal file
@ -0,0 +1,39 @@
|
||||
# textdomain: bulletin_boards
|
||||
|
||||
|
||||
### init.lua ###
|
||||
|
||||
#Appended to the title of a bulletin board to indicate how much it costs to post a bulletin here (@1 is a number, @2 is an item name)
|
||||
, Cost: @1 @2=, kostet: @1 @2
|
||||
#Tooltip. Title of bulletin, author of bulletin, how long ago the bulletin was posted.
|
||||
@1@nPosted by @2@n@3 days ago=@1@nGepostet von @2@nvor @3 Tagen
|
||||
#Button label
|
||||
Back to Board=Zurück zum Brett
|
||||
#Text area label
|
||||
Contents:=Inhalt:
|
||||
#title of a bulletin board
|
||||
Copper Board=Kupfernes Brett
|
||||
#tooltip
|
||||
Delete this bulletin=Lösche diesen Eintrag
|
||||
#label
|
||||
Delete:=Löschen:
|
||||
#Button label
|
||||
Next=Nächste
|
||||
#tooltip
|
||||
Post bulletin with this icon=Eintrag mit diesem Bild anschlagens
|
||||
#tooltip
|
||||
Post bulletin with this icon at the cost of @1 @2=Schlägt den Eintrag mit diesem Bild für @1 @2 an
|
||||
#tooltip
|
||||
Post your bulletin here=Schlage deinen Eintrag hier an
|
||||
#tooltip
|
||||
Post your bulletin here for the cost of @1 @2=Schlage deinen Eintrag für @1 @2 an
|
||||
#label
|
||||
Post:=Anschlagen:
|
||||
#player who posted this bulletin
|
||||
Posted by @1=Angeschlagen von @1
|
||||
#Button label, short for "previous"
|
||||
Prev=Vorher
|
||||
#title of a bulletin board
|
||||
Public Bulletin Board=Öffentliche Anschlagtafel
|
||||
#Text field label
|
||||
Title:=Titel:
|
39
bulletin_boards/locale/template.txt
Normal file
@ -0,0 +1,39 @@
|
||||
# textdomain: bulletin_boards
|
||||
|
||||
|
||||
### init.lua ###
|
||||
|
||||
#Appended to the title of a bulletin board to indicate how much it costs to post a bulletin here (@1 is a number, @2 is an item name)
|
||||
, Cost: @1 @2=
|
||||
#Tooltip. Title of bulletin, author of bulletin, how long ago the bulletin was posted.
|
||||
@1@nPosted by @2@n@3 days ago=
|
||||
#Button label
|
||||
Back to Board=
|
||||
#Text area label
|
||||
Contents:=
|
||||
#title of a bulletin board
|
||||
Copper Board=
|
||||
#tooltip
|
||||
Delete this bulletin=
|
||||
#label
|
||||
Delete:=
|
||||
#Button label
|
||||
Next=
|
||||
#tooltip
|
||||
Post bulletin with this icon=
|
||||
#tooltip
|
||||
Post bulletin with this icon at the cost of @1 @2=
|
||||
#tooltip
|
||||
Post your bulletin here=
|
||||
#tooltip
|
||||
Post your bulletin here for the cost of @1 @2=
|
||||
#label
|
||||
Post:=
|
||||
#player who posted this bulletin
|
||||
Posted by @1=
|
||||
#Button label, short for "previous"
|
||||
Prev=
|
||||
#title of a bulletin board
|
||||
Public Bulletin Board=
|
||||
#Text field label
|
||||
Title:=
|
3
bulletin_boards/mod.conf
Normal file
@ -0,0 +1,3 @@
|
||||
name=bulletin_boards
|
||||
description = Allows creation of global bulletin boards where players can post public messages
|
||||
optional_depends = default
|
13
bulletin_boards/readme.md
Normal file
@ -0,0 +1,13 @@
|
||||
## Bulletin boards
|
||||
|
||||
This mod adds global bulletin boards to Minetest. These are boards where players can post short notes for other players to see, at a nominal cost.
|
||||
|
||||
| ![](screenshot.png) | ![](screenshot_bulletin.png) |
|
||||
|
||||
Each board can hold up to 56 bulletins (in an 8 by 7 grid), with each bulletin having a title and an icon that can be set by the player posting it. Once the bulletin board nears capacity, older bulletins will start being culled from the board to make room for new bulletins. They will be culled by preference starting with the bulletins belonging to the players who have the most bulletins currently posted, followed by the oldest bulletin belonging to those players.
|
||||
|
||||
So for example, if Alice has 1 bulletin on the board, Bob has 2 bulletins on the board, and Collin has 2 bulletins on the board, then when it comes time to cull a bulletin the oldest one belonging to either Bob or Collin will be culled. If that happens to be Bob's, then next time it's time to cull Alice will have 1, Bob will have 1, and Collin will have 2, so the oldest of Collin's bulletins will be culled. This ordering is done to try to balance things out fairly - players that hog the board with multiple bulletins will have their bulletins culled more often, but everyone's single most recent bulletin will stick around as long as possible.
|
||||
|
||||
Bulletin boards can have a cost associated with posting. If the player has the cost (an item stack) in their inventory, they can post and the cost will be automatically deducted.
|
||||
|
||||
Boards are "global", in the sense that all instances of a given type of board will have the same content regardless of where they are in the world. This can make them useful as a means of communication over distance as well as time, serving as a sort of public post office.
|
BIN
bulletin_boards/screenshot.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
bulletin_boards/screenshot_bulletin.png
Normal file
After Width: | Height: | Size: 37 KiB |
BIN
bulletin_boards/textures/bulletin_boards_corkboard.png
Normal file
After Width: | Height: | Size: 802 B |
BIN
bulletin_boards/textures/bulletin_boards_delete.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
bulletin_boards/textures/bulletin_boards_document_back.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 1.1 KiB |
BIN
bulletin_boards/textures/bulletin_boards_document_image.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
bulletin_boards/textures/bulletin_boards_document_next.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
bulletin_boards/textures/bulletin_boards_document_notes.png
Normal file
After Width: | Height: | Size: 878 B |
BIN
bulletin_boards/textures/bulletin_boards_document_quote.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
bulletin_boards/textures/bulletin_boards_document_signature.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
bulletin_boards/textures/bulletin_boards_documents_email.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
bulletin_boards/textures/bulletin_boards_frame.png
Normal file
After Width: | Height: | Size: 114 B |
BIN
bulletin_boards/textures/bulletin_boards_frame_copper.png
Normal file
After Width: | Height: | Size: 114 B |
BIN
bulletin_boards/textures/bulletin_boards_receipt_invoice.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
bulletin_boards/textures/bulletin_boards_to_do_list.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
17
bulletin_boards/textures/license.txt
Normal file
@ -0,0 +1,17 @@
|
||||
bulletin_boards_corkboard.png and bulletin_boards_frame.png - by FaceDeer, released under the CC-0 public domain license
|
||||
|
||||
bulletin_boards_delete.png - from https://commons.wikimedia.org/wiki/File:Symbol_delete_vote_darkened.svg under the public domain
|
||||
|
||||
The following are from the "Farm-Fresh Web Icons" set, May 5 2014, from http://www.fatcow.com/free-icons by FatCow under the CC-BY-SA 3.0 unported license:
|
||||
bulletin_boards_document_back.png - https://commons.wikimedia.org/wiki/File:Farm-Fresh_document_back.png
|
||||
bulletin_boards_document_comment_above.png - https://commons.wikimedia.org/wiki/File:Farm-Fresh_document_comment_above.png
|
||||
bulletin_boards_document_image.png - https://commons.wikimedia.org/wiki/File:Farm-Fresh_document_image.png
|
||||
bulletin_boards_document_next.png - https://commons.wikimedia.org/wiki/File:Farm-Fresh_document_next.png
|
||||
bulletin_boards_document_notes.png - https://commons.wikimedia.org/wiki/File:Farm-Fresh_document_notes.png
|
||||
bulletin_boards_document_quote.png - https://commons.wikimedia.org/wiki/File:Farm-Fresh_document_quote.png
|
||||
bulletin_boards_document_signature.png - https://commons.wikimedia.org/wiki/File:Farm-Fresh_document_signature.png
|
||||
bulletin_boards_documents_email.png - https://commons.wikimedia.org/wiki/File:Farm-Fresh_documents_email.png
|
||||
bulletin_boards_receipt_invoice.png - https://commons.wikimedia.org/wiki/File:Farm-Fresh_receipt_invoice.png
|
||||
bulletin_boards_to_do_list.png - https://commons.wikimedia.org/wiki/File:Farm-Fresh_to_do_list.png
|
||||
|
||||
The Farm-Fresh Web Icons were found via found from https://commons.wikimedia.org/wiki/Category:Document_icons if you want more of them
|