428 lines
16 KiB
Python
Executable File
428 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import os
|
|
import random
|
|
import urllib
|
|
import uuid
|
|
|
|
import flask
|
|
import flask_socketio
|
|
import pony
|
|
from pony.flask import Pony
|
|
|
|
word_file = "/usr/share/dict/words"
|
|
WORDS = open(word_file).read().splitlines()
|
|
|
|
app = flask.Flask(__name__)
|
|
socketio = flask_socketio.SocketIO(app)
|
|
|
|
db = None
|
|
# @TODO Change this into using redis or something else for storage across
|
|
# threads etc.
|
|
session_map = {}
|
|
|
|
@app.route('/')
|
|
@pony.orm.db_session
|
|
def index():
|
|
r = flask.Response()
|
|
uid, user = get_uid(r)
|
|
negotiations = pony.orm.select(n for n in Negotiation if n.owner == user)
|
|
suggestion = suggest_unused_name()
|
|
r.data = render_page('index.html', {
|
|
'negotiations': list(negotiations),
|
|
'suggestion': suggestion,
|
|
})
|
|
return r
|
|
|
|
@pony.orm.db_session
|
|
def get_uid(r, create = True):
|
|
"""
|
|
Returns a tuple (uid, created) where created is true
|
|
if the user was new.
|
|
|
|
r is the flask response, so that the cookie uid may be set if necessary.
|
|
"""
|
|
uid = flask.request.cookies.get('uid')
|
|
if not uid:
|
|
if create:
|
|
uid = generate_uid()
|
|
r.set_cookie('uid', uid)
|
|
else:
|
|
return None, None
|
|
user = pony.orm.select(u for u in User if u.uid == uid).first()
|
|
if not user:
|
|
# Recreate the database user if it doesn't exist
|
|
user = User(uid = uid)
|
|
return uid, user
|
|
|
|
@app.route('/negotiation/create', methods = ['POST'])
|
|
@pony.orm.db_session
|
|
def negotiation_create():
|
|
n = flask.request.form.get('negotiation')
|
|
uid, user = get_uid(None, False)
|
|
if not uid or not user:
|
|
return flask.redirect('/') # @TODO Signal error to user. They don't have a uid cookie
|
|
if len(n) > 128:
|
|
return flask.redirect('/') # @TODO Signal error to user. Request too Large
|
|
nego = get_negotiation(n)
|
|
if nego:
|
|
# @TODO Signal to the user that the negotiation already exists and they
|
|
# are being sent to it instead.
|
|
return flask.redirect('/negotiations/{}'.format(n))
|
|
nego = Negotiation(name = n, owner = user.id, taker_sway = 1,
|
|
market_sway = 6, is_first_negotiation = True,
|
|
manual_negotiation = True, current_phase = 'marketprep',
|
|
)
|
|
pony.orm.commit()
|
|
return flask.redirect('/negotiations/{}'.format(n))
|
|
|
|
@app.route('/negotiations/')
|
|
def negotiation_redir():
|
|
return flask.redirect('/')
|
|
|
|
@app.route('/negotiations/<negotiation>')
|
|
def negotiation(negotiation):
|
|
r = flask.Response()
|
|
uid, user = get_uid(r)
|
|
nego = get_negotiation(negotiation)
|
|
if not nego:
|
|
return "Error!"
|
|
c = {
|
|
'negotiation': nego,
|
|
'user': uid,
|
|
'room_owner': nego.owner,
|
|
'room_owner_display_name': nego.owner.display_name if nego.owner.display_name else nego.owner.uid,
|
|
'participant_count': len(get_room_participants(negotiation)),
|
|
'phase': get_negotiation_phase(nego),
|
|
}
|
|
r.data = render_page('negotiation.html', c)
|
|
return r
|
|
|
|
@socketio.on('join negotiation')
|
|
def handle_join_negotiation(json):
|
|
r = flask.Response()
|
|
app.logger.info('Received join request: {}'.format(json))
|
|
uid, user = get_uid(r)
|
|
flask_socketio.join_room(json['room'])
|
|
session_map[flask.request.sid] = uid;
|
|
app.logger.info('{} has joined room "{}" (sid: {})'.format(uid, json['room'],
|
|
flask.request.sid))
|
|
participants = get_room_participants(json['room'])
|
|
app.logger.info('{} has {} connections'.format(
|
|
json['room'], len(participants)))
|
|
flask_socketio.emit('participants changed', {'participants': participants},
|
|
room = json['room'])
|
|
# Update the user's negotation state with what is stored on the server
|
|
nego = get_negotiation(json['room'])
|
|
# Send the newly arrived use the computed phase information
|
|
phase = get_negotiation_phase(nego);
|
|
return {'negotiation': nego.to_dict(), 'phase': phase}
|
|
|
|
phases = {
|
|
'marketprep': {
|
|
'previous': None,
|
|
'next': 'takerprep',
|
|
'display_name': 'Market Prep',
|
|
'description': 'Set up the negotiation\'s basic information in the UI, under "Negotiation Settings". When you\'re done, click on the "Next" button (right hand swip image)',
|
|
'description_taker': 'The Market is setting up the negotiation, hang on.',
|
|
'has_sub_phases': False,
|
|
},
|
|
'takerprep': {
|
|
'previous': 'marketprep',
|
|
'next': 'negotiatorselection',
|
|
'display_name': 'Taker Prep',
|
|
'description': 'Each taker may take one of the prep actions: x, y, z',
|
|
'has_sub_phases': False,
|
|
},
|
|
'negotiatorselection': {
|
|
'previous': 'takerprep',
|
|
'next': 'firstimpression',
|
|
'display_name': 'Negotiator Selection',
|
|
'description': 'The crew selectons one member to be the negotiator. This character will perform all of the negotiation actions, but may be supported by the rest of the crew during scams.',
|
|
'has_sub_phases': False,
|
|
},
|
|
'firstimpression': {
|
|
'previous': 'negotiatorselection',
|
|
'next': 'negotiation',
|
|
'display_name': 'First Impression',
|
|
'description': 'The taker crew\'s negotiator rolls a leadership check. The results of this check determine the number of rounds that a negotiation lasts, the taker\'s starting position on the sway tracker, if the negotiation length is known to the takers, and who will lead in each negotiation round.',
|
|
'has_sub_phases': False,
|
|
},
|
|
'negotiation': {
|
|
'previous': 'firstimpression',
|
|
'next': 'wrapup',
|
|
'display_name': 'Negotiation',
|
|
'description': 'A round robin of: both negotiators playing tactics in order, checking if the negotiation should end, and a scam if not all the remaining takers have done one already.',
|
|
'has_sub_phases': True,
|
|
'sub_phases_repeat': True,
|
|
'sub_phases': [
|
|
'first_negotiator_tactic',
|
|
'second_negotiatior_tactic',
|
|
'check_negotiation_end',
|
|
'scam',
|
|
],
|
|
},
|
|
'wrapup': {
|
|
'previous': 'negotiation',
|
|
'next': 'done',
|
|
'display_name': 'Wrap-Up',
|
|
'description': 'Final details to close out the negotiation',
|
|
'has_sub_phases': True,
|
|
'sub_phases_repeat': False,
|
|
'sub_phases': [
|
|
'leadership',
|
|
'accept_backout',
|
|
'undercut',
|
|
],
|
|
},
|
|
'done': {
|
|
'previous': 'wrapup',
|
|
'next': None,
|
|
'display_name': 'Done',
|
|
'description': 'The negotiation is concluded',
|
|
'has_sub_phases': False,
|
|
},
|
|
}
|
|
|
|
def get_negotiation_phase(nego):
|
|
r = dict();
|
|
r['current_phase'] = nego.current_phase
|
|
r['current_sub_phase'] = nego.current_sub_phase
|
|
r['display_name'] = phases[r['current_phase']]['display_name'] if r['current_phase'] in phases else 'Unknown phase "{}"'.format(r['current_phase'])
|
|
r['description'] = phases[r['current_phase']]['description'] if r['current_phase'] in phases else 'Unknown phase "{}"'.format(r['current_phase'])
|
|
r['description_taker'] = phases[r['current_phase']]['description_taker'] if 'description_taker' in phases[r['current_phase']].keys() else r['description']
|
|
# Can the takers see the round information?
|
|
r['visible_rounds'] = True
|
|
r['round_current'] = nego.negotiation_round
|
|
try:
|
|
r['round_max'] = max(floor(nego.first_impression_black / 2), 5)
|
|
except:
|
|
r['round_max'] = 'First impression not yet done'
|
|
r['previous_phase'] = phases[nego.current_phase]['previous']
|
|
r['next_phase'] = phases[nego.current_phase]['next']
|
|
return r
|
|
|
|
@socketio.on('set next phase')
|
|
@pony.orm.db_session
|
|
def handle_set_next_phase(json):
|
|
r = flask.Response()
|
|
app.logger.info('Received set next phase request: {}'.format(json))
|
|
uid, user = get_uid(r)
|
|
nego = get_negotiation(json['room'])
|
|
app.logger.info('Room owner is {}'.format(nego.owner.uid))
|
|
if nego.owner.uid != uid:
|
|
# Refuse the update from non-owners
|
|
app.logger.warning('Refusing set next phase of {} from non-owner {}'.format(json['room'], uid))
|
|
return False
|
|
current_phase = nego.current_phase
|
|
next_phase_key = phases[current_phase]['next'];
|
|
if next_phase_key is None:
|
|
# @TODO. Anything here?
|
|
return False
|
|
# @TODO Handle sub phases
|
|
nego.current_phase = next_phase_key
|
|
pony.orm.commit()
|
|
data = {
|
|
'phase': get_negotiation_phase(nego),
|
|
'transitioned_from': current_phase,
|
|
}
|
|
flask_socketio.emit('negotiation state changed', {**data}, room = json['room'])
|
|
return True
|
|
|
|
@socketio.on('set previous phase')
|
|
@pony.orm.db_session
|
|
def handle_set_prev_phase(json):
|
|
r = flask.Response()
|
|
app.logger.info('Received set previous phase request: {}'.format(json))
|
|
uid, user = get_uid(r)
|
|
nego = get_negotiation(json['room'])
|
|
app.logger.info('Room owner is {}'.format(nego.owner.uid))
|
|
if nego.owner.uid != uid:
|
|
# Refuse the update from non-owners
|
|
app.logger.warning('Refusing set previous phase of {} from non-owner {}'.format(json['room'], uid))
|
|
return False
|
|
current_phase = nego.current_phase
|
|
next_phase_key = phases[current_phase]['previous'];
|
|
if next_phase_key is None:
|
|
# @TODO. Anything here?
|
|
return False
|
|
# @TODO Handle sub phases
|
|
nego.current_phase = next_phase_key
|
|
pony.orm.commit()
|
|
data = {
|
|
'phase': get_negotiation_phase(nego),
|
|
'transitioned_from': current_phase,
|
|
}
|
|
flask_socketio.emit('negotiation state changed', {**data}, room = json['room'])
|
|
return True
|
|
|
|
def get_room_participants(room):
|
|
if '/' not in socketio.server.manager.get_namespaces():
|
|
return []
|
|
try:
|
|
sessions = [s for s in socketio.server.manager.get_participants('/', room)]
|
|
participants = [p for s,p in session_map.items() if s in sessions]
|
|
except:
|
|
return []
|
|
app.logger.debug(participants)
|
|
return participants
|
|
|
|
@socketio.on('update negotiation')
|
|
@pony.orm.db_session
|
|
def handle_update_negotiation(json):
|
|
r = flask.Response()
|
|
app.logger.info('Received update request: {}'.format(json))
|
|
uid, user = get_uid(r)
|
|
nego = get_negotiation(json['room'])
|
|
app.logger.info('Room owner is {}'.format(nego.owner.uid))
|
|
if nego.owner.uid != uid:
|
|
# Refuse the update from non-owners
|
|
app.logger.warning('Refusing update of {} from non-owner {}'.format(json['room'], uid))
|
|
return False
|
|
del json['room']
|
|
# If a negotiation was set to manual, it may not be set back to automatic.
|
|
if nego.manual_negotiation and 'manual_negotiation' in json:
|
|
del json['manual_negotiation']
|
|
nego.set(**json)
|
|
pony.orm.commit()
|
|
flask_socketio.emit('negotiation updated', {**json}, room = nego.name)
|
|
return True
|
|
|
|
@socketio.on('leave negotiation')
|
|
def handle_leave_disconnect(json):
|
|
r = flask.Response()
|
|
app.logger.info('Received join request: {}'.format(json))
|
|
uid, user = get_uid(r)
|
|
del session_map[flask.request.sid]
|
|
app.logger.info('{} has disconnected from {} (sid: {})'.format(uid, json['room'],flask.request.sid))
|
|
participants = get_room_participants(json['room'])
|
|
flask_socketio.emit('participants changed', {'participants': participants},
|
|
room = json['room'])
|
|
return True
|
|
|
|
@pony.orm.db_session
|
|
def get_negotiation(negotiation):
|
|
return pony.orm.select(nego for nego in Negotiation if nego.name == negotiation).first()
|
|
|
|
@pony.orm.db_session
|
|
def generate_uid():
|
|
while True:
|
|
uid = str(uuid.uuid4())
|
|
if pony.orm.exists(u for u in User if u.uid == uid):
|
|
continue
|
|
u = User(uid = uid)
|
|
pony.orm.commit()
|
|
return uid
|
|
|
|
@pony.orm.db_session
|
|
def suggest_unused_name():
|
|
tries = 0;
|
|
while True:
|
|
tries += 1
|
|
w = "{}{}{}{}".format(
|
|
random.choice(WORDS).title(),
|
|
random.choice(WORDS).title(),
|
|
random.choice(WORDS).title(),
|
|
random.choice(WORDS).title()
|
|
)
|
|
if not w.isalpha():
|
|
continue
|
|
if pony.orm.exists(n.name for n in Negotiation if n.name == w):
|
|
continue
|
|
app.logger.info('Took {} tries to generate a room suggestion'.format(tries))
|
|
return w
|
|
|
|
def render_page(template, context):
|
|
app_context = {
|
|
'LANGUAGE': flask.request.cookies.get('language')
|
|
if flask.request.cookies.get('language') else app.config['DEFAULT_LANGUAGE'],
|
|
}
|
|
context = {**context, **app_context}
|
|
return flask.render_template(template, **context)
|
|
|
|
if __name__ == '__main__':
|
|
port = os.environ.get('APP_PORT', 5000)
|
|
appkey = os.environ.get('APP_KEY', 'test')
|
|
bindaddr = os.environ.get('APP_BINDADDRESS', '127.0.0.1')
|
|
app.secret_key = appkey
|
|
app.config.update(dict(
|
|
PONY = {
|
|
'provider': 'sqlite',
|
|
'filename': 'negotiation.db',
|
|
'create_db': True,
|
|
},
|
|
DEFAULT_LANGUAGE = 'en',
|
|
))
|
|
db = pony.orm.Database()
|
|
class User(db.Entity):
|
|
uid = pony.orm.Required(str)
|
|
display_name = pony.orm.Optional(str)
|
|
negotiations = pony.orm.Set('Negotiation')
|
|
|
|
# Negotiations have a state. Maybe storing a state machine object (pickle?)
|
|
# would be an idea. But maybe not.
|
|
#
|
|
# Let's start with understanding negotiations
|
|
# 1. Prep Work: Before a negotiation begins, each take may do Prep Work
|
|
# 2. The takers decide who their Lead Negotiator will be. Once set, this
|
|
# cannot be changed.
|
|
# 3. The Lead Negotiation makes a First Impression
|
|
# This is a leadership check which has four effects:
|
|
# 1. The number of rounds of negotiation: max(floor(black+ld/2), 5)
|
|
# 2. The starting position (crit success +1 sway, crit failure: -1 sway)
|
|
# 3. Whether or not the negotiation length is known to takers. T/F based
|
|
# simple succes.
|
|
# 4. Who leads the negotiations (starting round): takers on success, Market otherwise.
|
|
# 4. Negotiation Round(s)
|
|
# 1. Lead negotiator plays a negotiation tactic
|
|
# 2. Other negotiator plays a negotiation tactic
|
|
# 3. Check negotiation end condition
|
|
# 4. If not ended, a non-negotiator taker may play scam
|
|
# 5. Wrap-up
|
|
# 1. Taker negotiator rolls Leadership. Success moves up to meet Market,
|
|
# failure moves market down to meet takers.
|
|
# 2a. Takers may accept the deal.
|
|
# 2b. Takers may back out. Next negotiation starts on lowest tracker, has
|
|
# no prep work, scams, or will.
|
|
# 3. Competition undercuts. If able, the competition will undercut by one
|
|
# spot on the way tracker. _Any_ taker may make a CHA check to stop this,
|
|
# but on failure the takers must lower theire price to match the competition.
|
|
#
|
|
class Negotiation(db.Entity):
|
|
name = pony.orm.PrimaryKey(str)
|
|
owner = pony.orm.Required(User)
|
|
client_name = pony.orm.Optional(str)
|
|
negotiator_name = pony.orm.Optional(str)
|
|
taker_crew_name = pony.orm.Optional(str)
|
|
# Manual negotation
|
|
manual_negotiation = pony.orm.Optional(bool)
|
|
# marketprep, takerprep, negotiatorselection, firstimpression,
|
|
# negotiation (first, second, checkendcondition, scam)
|
|
# wrapup ( leadership, accept_refuse, undercut)
|
|
current_phase = pony.orm.Optional(str)
|
|
current_sub_phase = pony.orm.Optional(str)
|
|
# Pre-negotiation details
|
|
takers = pony.orm.Optional(int)
|
|
lead_negotiator = pony.orm.Optional(str)
|
|
is_first_negotiation = pony.orm.Optional(bool)
|
|
first_impression_black = pony.orm.Optional(int)
|
|
# One of critfail, fail, success, critsuccess
|
|
first_impression_state = pony.orm.Optional(str)
|
|
negotiation_round = pony.orm.Optional(int)
|
|
# One of leadnego, secondnego, scam
|
|
negotiation_phase = pony.orm.Optional(str)
|
|
taker_sway = pony.orm.Optional(int)
|
|
market_sway = pony.orm.Optional(int)
|
|
# If negotiation_round = (calculed length) && negotiation_phase = scam then
|
|
# the negotiation is no into the wrap up section
|
|
# One of critfail, fail, success, critsuccess
|
|
wrapup_state = pony.orm.Optional(int)
|
|
job_accepted = pony.orm.Optional(bool)
|
|
may_be_undercut = pony.orm.Optional(bool)
|
|
undercut = pony.orm.Optional(bool)
|
|
|
|
db.bind(**app.config['PONY'])
|
|
db.generate_mapping(create_tables=True)
|
|
Pony(app)
|
|
socketio.run(app, host = bindaddr, port = port)
|