Enables variance in AdditionalContent based on achievements granted to a particular team (#44)

* WIP

* Enables content branching by achievement
This commit is contained in:
Alex Marcellus 2024-07-22 13:36:43 -07:00 коммит произвёл GitHub
Родитель 8cccad906b
Коммит 964092b720
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
16 изменённых файлов: 112 добавлений и 23 удалений

Просмотреть файл

@ -9,6 +9,7 @@ import { getStaffPuzzleDetails } from 'modules'
import { getStaffTeams, shouldRefreshTeams, shouldRefreshClues } from 'modules/staff';
import { addAnswerToClue, addContentToClue, addLocationToClue, createClue, deleteClue, deleteContent, fetchStaffClueDetails, fetchStaffClues, relockClueForTeam, unlockClueForTeam } from 'modules/staff/clues/service';
import { fetchStaffTeams } from "modules/staff/teams/service";
import { fetchStaffAchievements, getAchievementsModule, shouldRefreshAchievements } from 'modules/staff/achievements';
import { AnswerForm, ClueForm, ContentForm, LocationForm } from './dialogs';
import DialogRenderProp from './dialogs/DialogRenderProp';
@ -70,6 +71,11 @@ class StaffClueDetails extends React.Component {
if (shouldRefreshTeams(this.props.teams)) {
this.props.fetchTeams();
}
// Need the list of achievements for achievement-specific content unlocks
if (shouldRefreshAchievements(this.props.achievements)) {
this.props.fetchStaffAchievements();
}
}
componentWillReceiveProps(newProps) {
@ -163,6 +169,7 @@ class StaffClueDetails extends React.Component {
renderButton={() => <><FaImages/> Add Content</>}
renderBody={onComplete =>
<ContentForm
achievements={this.props.achievements.data}
onSubmit={content => {
this.props.addContentToClue(foundClue.tableOfContentId, content);
onComplete();
@ -186,6 +193,7 @@ class StaffClueDetails extends React.Component {
/>
<StaffClueContent
content={foundClue.content}
achievements={this.props.achievements.data}
tableOfContentId={foundClue.tableOfContentId}
addContentToClue={this.props.addContentToClue}
addLocationToClue={this.props.addLocationToClue}
@ -240,6 +248,7 @@ class StaffClueDetails extends React.Component {
const mapStateToProps = (state, initProps) => ({
user: state.user,
clues: getStaffClues(state),
achievements: getAchievementsModule(state),
currentClue: getStaffPuzzleDetails(state, initProps.match.params.id),
teams: getStaffTeams(state)
})
@ -256,6 +265,7 @@ const mapDispatchToProps = (dispatch) => ({
deleteContent: (tableOfContentId, contentId) => dispatch(deleteContent(tableOfContentId, contentId)),
unlockClueForTeam: (teamId, tableOfContentId, reason) => dispatch(unlockClueForTeam(teamId, tableOfContentId, reason)),
relockClueForTeam: (teamId, tableOfContentId) => dispatch(relockClueForTeam(teamId, tableOfContentId)),
fetchStaffAchievements: () => dispatch(fetchStaffAchievements())
})
export default connect(mapStateToProps, mapDispatchToProps)(StaffClueDetails);

Просмотреть файл

@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { Button, Form, FormGroup } from 'react-bootstrap'
import CodeMirror from 'react-codemirror';
import { Content } from 'modules/types';
import { Achievement, Content } from 'modules/types';
import { ContentTemplate } from 'modules/staff/clues/models';
require('codemirror/mode/htmlmixed/htmlmixed');
@ -10,15 +10,17 @@ require('codemirror/lib/codemirror.css');
type Props = Readonly<{
content?: Content;
achievements?: Array<Achievement>
onSubmit: (contentTemplate: ContentTemplate) => void;
}>;
export const ContentForm = ({ content, onSubmit }: Props) => {
export const ContentForm = ({ content, achievements, onSubmit }: Props) => {
const [contentId] = useState(content?.contentId ?? undefined);
const [contentName, setContentName] = useState(content?.name ?? '');
const [contentType, setContentType] = useState(content?.contentType ?? 'PlainText');
const [stringContent, setStringContent] = useState(content?.stringContent ?? '');
const [binaryContent, setBinaryContent] = useState(null);
const [achievementUnlockId, setAchievementUnlockId] = useState(content?.achievementUnlockId ?? undefined);
const codeMirror = useRef<ReactCodeMirror.ReactCodeMirror | null>(null);
@ -102,6 +104,19 @@ export const ContentForm = ({ content, onSubmit }: Props) => {
<Form.Control.Feedback type="invalid">Content must not be empty, and if a youtube URL must include embed in the URL</Form.Control.Feedback>
</FormGroup>
}
<FormGroup>
<Form.Label>Unlocked by Achievement</Form.Label>
<Form.Control as="select"
disabled={!!content || !achievements}
value={achievementUnlockId}
onChange={event => setAchievementUnlockId(event.target.value)}>
<option value="" selected>None</option>
{
achievements?.map((achievement) => <option value={achievement.achievementId}>{achievement.name}</option>)
}
</Form.Control>
</FormGroup>
<Button onClick={() => {
onSubmit({
@ -109,7 +124,8 @@ export const ContentForm = ({ content, onSubmit }: Props) => {
contentName,
contentType,
binaryContent: contentType === 'Image' ? binaryContent : undefined,
stringContent: contentName !== 'Image' ? stringContent : undefined
stringContent: contentName !== 'Image' ? stringContent : undefined,
achievementUnlockId
});
}}>
{content ? 'Update' : 'Add'}

Просмотреть файл

@ -3,7 +3,7 @@ import { FiExternalLink } from 'react-icons/fi';
import { useSelector } from 'react-redux';
import _ from 'lodash';
import { Content } from "modules/types";
import { Achievement, Content } from "modules/types";
type ContentProps = Readonly<{
content: Content;
@ -83,9 +83,10 @@ type AdditionalContentProps = Readonly<{
content: Content;
teamId?: string;
playerId?: string;
achievements?: Achievement[]
}>;
export const AdditionalContent = ({ content, teamId, playerId }: AdditionalContentProps) => {
export const AdditionalContent = ({ content, teamId, playerId, achievements }: AdditionalContentProps) => {
let tokenReplacedContent = { ...content };
let teamShortName = useSelector(store => _.get(store, "user.teamShortName"));
@ -100,19 +101,38 @@ export const AdditionalContent = ({ content, teamId, playerId }: AdditionalConte
tokenReplacedContent.stringContent = tokenReplacedContent.stringContent.replace(/\[\[shortName\]\]/g, teamShortName.trim());
}
let returnContent = <></>;
if (content.contentType.trim() === "PlainText") {
return <TextContent content={tokenReplacedContent} />
returnContent = <TextContent content={tokenReplacedContent} />
} else if (content.contentType.trim() === "RichText") {
return <HtmlContent content={tokenReplacedContent} />
returnContent = <HtmlContent content={tokenReplacedContent} />
} else if (content.contentType.trim() === "Location") {
return <LocationContent content={tokenReplacedContent} />
returnContent = <LocationContent content={tokenReplacedContent} />
} else if (content.contentType.trim() === "Hyperlink") {
return <HyperlinkContent content={tokenReplacedContent} />
returnContent = <HyperlinkContent content={tokenReplacedContent} />
} else if (content.contentType.trim() === "Image") {
return <ImageContent content={tokenReplacedContent} />
returnContent = <ImageContent content={tokenReplacedContent} />
} else if (content.contentType.trim() === "YoutubeUrl") {
return <YoutubeContent content={tokenReplacedContent} />
returnContent = <YoutubeContent content={tokenReplacedContent} />
}
let matchingAchievement = undefined;
if (content.achievementUnlockId && achievements)
{
matchingAchievement = achievements.find(achievement => achievement.achievementId === content.achievementUnlockId);
}
if (matchingAchievement)
{
return <div>
{returnContent}
<br />
<b>NOTE: The above content is unlocked by achievement:</b>
<p>{matchingAchievement.name}</p>
</div>
}
else
{
return returnContent;
}
return <></>;
};

Просмотреть файл

@ -2,7 +2,7 @@ import { Button, Card, Col, Container, Row } from "react-bootstrap";
import { FaEdit, FaTrashAlt, FaAngleDoubleUp } from 'react-icons/fa';
import moment from "moment";
import { Content, SkipPlot, SolvedPlot, UnsolvedPlot } from "modules/types";
import { Achievement, Content, SkipPlot, SolvedPlot, UnsolvedPlot } from "modules/types";
import { ContentTemplate, LocationTemplate } from "modules/staff/clues";
import DialogRenderProp from '../dialogs/DialogRenderProp';
@ -11,6 +11,7 @@ import { AdditionalContent } from "../presentation/AdditionalContent";
type Props = Readonly<{
content: Content[];
achievements: Achievement[];
tableOfContentId: string;
addContentToClue: (tableOfContentId: string, contentTemplate: ContentTemplate) => void;
addLocationToClue: (tableOfContentId: string, locationTemplate: LocationTemplate) => void;
@ -19,13 +20,14 @@ type Props = Readonly<{
type ActionProps = Readonly<{
contentItem: Content;
achievements: Achievement[]
tableOfContentId: string;
addContentToClue: (tableOfContentId: string, contentTemplate: ContentTemplate) => void;
addLocationToClue: (tableOfContentId: string, locationTemplate: LocationTemplate) => void;
deleteContent: (tableOfContentId: string, contentId: string) => void;
}>;
const ContentActions = ({ addContentToClue, addLocationToClue, deleteContent, tableOfContentId, contentItem }: ActionProps) => {
const ContentActions = ({ addContentToClue, addLocationToClue, deleteContent, tableOfContentId, contentItem, achievements }: ActionProps) => {
return (
<Row>
<Col style={{ alignContent: 'center' }}>
@ -49,6 +51,7 @@ const ContentActions = ({ addContentToClue, addLocationToClue, deleteContent, ta
return (
<ContentForm
content={contentItem}
achievements={achievements}
onSubmit={content => {
addContentToClue(tableOfContentId, content);
onComplete();
@ -74,13 +77,14 @@ type ContentCardProps = Readonly<{
title: string;
description: JSX.Element;
contentList: Content[];
achievements: Achievement[];
tableOfContentId: string;
addContentToClue: (tableOfContentId: string, contentTemplate: ContentTemplate) => void;
addLocationToClue: (tableOfContentId: string, locationTemplate: LocationTemplate) => void;
deleteContent: (tableOfContentId: string, contentId: string) => void;
}>;
const ContentCard = ({ title, description, contentList, tableOfContentId, addContentToClue, addLocationToClue, deleteContent }: ContentCardProps) => {
const ContentCard = ({ title, description, contentList, achievements, tableOfContentId, addContentToClue, addLocationToClue, deleteContent }: ContentCardProps) => {
if (contentList.length > 0) {
return (
<Card style={{ justifySelf: "center", margin: "40px" }}>
@ -90,17 +94,18 @@ const ContentCard = ({ title, description, contentList, tableOfContentId, addCon
</Card.Header>
<Card.Text>
<Container fluid>
{contentList.map(content =>
{contentList.map(content =>
<>
<Row style={{ margin: "15px" }}>
<AdditionalContent content={content}/>
<AdditionalContent content={content} achievements={achievements}/>
</Row>
<ContentActions
addContentToClue={addContentToClue}
addLocationToClue={addLocationToClue}
deleteContent={deleteContent}
tableOfContentId={tableOfContentId}
contentItem={content} />
contentItem={content}
achievements={achievements} />
</>
)}
</Container>
@ -112,7 +117,7 @@ const ContentCard = ({ title, description, contentList, tableOfContentId, addCon
}
};
export const StaffClueContent = ({ content, tableOfContentId, addContentToClue, addLocationToClue, deleteContent }: Props) => {
export const StaffClueContent = ({ content, achievements, tableOfContentId, addContentToClue, addLocationToClue, deleteContent }: Props) => {
const sortedContent = content.sort((a, b) => moment.utc(b.lastUpdated).diff(moment.utc(a.lastUpdated)));
if (content.length > 0) {
@ -125,6 +130,7 @@ export const StaffClueContent = ({ content, tableOfContentId, addContentToClue,
<>
<ContentCard
title="Unsolved Plot"
achievements={achievements}
description={<em>Teams will see this on both the home page and puzzle page, but only when the puzzle is <strong>unsolved</strong>.</em>}
contentList={unsolvedPlotContent}
tableOfContentId={tableOfContentId}
@ -135,6 +141,7 @@ export const StaffClueContent = ({ content, tableOfContentId, addContentToClue,
<ContentCard
title="Solved Plot"
achievements={achievements}
description={<em>Teams will see this on both the home page and puzzle page, but only when the puzzle is <strong>solved</strong>.</em>}
contentList={solvedPlotContent}
tableOfContentId={tableOfContentId}
@ -145,6 +152,7 @@ export const StaffClueContent = ({ content, tableOfContentId, addContentToClue,
<ContentCard
title="Skip Plot"
achievements={achievements}
description={<em>If a team is <strong>skipped</strong> over this clue, they will see this on the home page, but this clue will not show up on all clues.</em>}
contentList={skippedPlotContent}
tableOfContentId={tableOfContentId}
@ -155,6 +163,7 @@ export const StaffClueContent = ({ content, tableOfContentId, addContentToClue,
<ContentCard
title="Other Content"
achievements={achievements}
description={<em>Teams will only see this on the puzzle page, regardless of solve status</em>}
contentList={otherContent}
tableOfContentId={tableOfContentId}

Просмотреть файл

@ -51,6 +51,7 @@ export type ContentTemplate = Readonly<{
contentName: string;
stringContent?: string;
binaryContent?: any;
achievementUnlockId?: string;
}>;
export type LocationTemplate = Readonly<{

Просмотреть файл

@ -113,6 +113,7 @@ export const addContentToClue = (tableOfContentId: string, contentTemplate: Cont
body.append("contentName", contentTemplate.contentName);
body.append("contentType", contentTemplate.contentType);
body.append("binaryContent", contentTemplate.binaryContent);
contentTemplate.achievementUnlockId && body.append("achievementUnlockId", contentTemplate.achievementUnlockId);
doServiceRequest(
dispatch,

Просмотреть файл

@ -50,6 +50,7 @@ export type Content = Readonly<{
latitude?: number;
longitude?: number;
locationFlags?: number;
achievementUnlockId?: string;
}>;
export type PlayerSubmission = Readonly<{

Просмотреть файл

@ -1,5 +1,7 @@
USE [gamecontrol]
GO
SET QUOTED_IDENTIFIER ON
GO
INSERT INTO [dbo].[Event] ([EventId], [EventName])
VALUES (
'88888888-8888-8888-8888-888888888888',

Просмотреть файл

@ -731,6 +731,7 @@ CREATE TABLE [dbo].[AdditionalContent](
[LastUpdate] [datetime] NOT NULL,
[EncryptionKey] [varbinary](max) NULL,
[ContentText] [nvarchar](max) NULL,
[AchievementUnlockId] [uniqueidentifier] NULL,
CONSTRAINT [PK_AdditionalContent] PRIMARY KEY CLUSTERED
(
[ContentId] ASC
@ -3120,6 +3121,7 @@ BEGIN
AdditionalContent.LastUpdate as ContentLastUpdated,
AdditionalContent.EncryptionKey,
AdditionalContent.ContentText,
AdditionalContent.AchievementUnlockId,
Location.LocationId,
Location.Name AS LocationName,
Location.LastUpdate as LocationLastUpdated,
@ -3159,6 +3161,7 @@ BEGIN
AdditionalContent.LastUpdate as ContentLastUpdated,
AdditionalContent.EncryptionKey,
AdditionalContent.ContentText,
AdditionalContent.AchievementUnlockId,
Location.LocationId,
Location.Name AS LocationName,
Location.LastUpdate as LocationLastUpdated,

Просмотреть файл

@ -15,6 +15,7 @@ using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Net;
@ -50,6 +51,7 @@ namespace GameControl.Server.Controllers.Player
var eventSubmissions = this.dbContext.GetValidSubmissions(eventInstanceId).ToList();
var sortOverridesRow = this.dbContext.TeamAdditionalData.AsNoTracking().FirstOrDefault(p => p.Team == participation.Team.Value && p.DataKey == TeamSortOverrideViewModel.AdditionalDataKey);
var achievementIds = this.dbContext.GetAchievementsForTeam(participation.Team.Value).Select(a => a.AchievementId).ToImmutableHashSet();
var overrides = new List<TeamSortOverrideViewModel>();
Dictionary<Guid, List<AdditionalContentForTeam>> contentDictionary = new Dictionary<Guid, List<AdditionalContentForTeam>>();
@ -86,13 +88,21 @@ namespace GameControl.Server.Controllers.Player
clue.Content = new List<ContentViewModel>();
if (contentDictionary.ContainsKey(clue.TableOfContentId))
{
IEnumerable<AdditionalContentForTeam> contentUnlockedByAchievements = contentDictionary[clue.TableOfContentId]
.Where(p => !p.AchievementUnlockId.HasValue || achievementIds.Contains(p.AchievementUnlockId.Value));
if (clue.IsSolved)
{
clue.Content = contentDictionary[clue.TableOfContentId].Where(p => p.ContentName != ContentViewModel.UnsolvedPlot && p.ContentName != ContentViewModel.SkipPlot).Select(p => new ContentViewModel(p));
clue.Content = contentUnlockedByAchievements
.Where(p => p.ContentName != ContentViewModel.UnsolvedPlot && p.ContentName != ContentViewModel.SkipPlot)
.Select(p => new ContentViewModel(p));
}
else
{
clue.Content = contentDictionary[clue.TableOfContentId].Where(p => p.ContentName != ContentViewModel.SolvedPlot && p.ContentName != ContentViewModel.SkipPlot).Select(p => new ContentViewModel(p));
clue.Content = contentUnlockedByAchievements
.Where(p => p.ContentName != ContentViewModel.SolvedPlot && p.ContentName != ContentViewModel.SkipPlot)
.Select(p => new ContentViewModel(p));
}
}

Просмотреть файл

@ -20,6 +20,8 @@ namespace GameControl.Server.Database.SprocTypes
public string ContentText { get; set; }
public Guid? AchievementUnlockId { get; set; }
public string ShortName { get; set; }
public string FileName { get; set; }

Просмотреть файл

@ -16,6 +16,8 @@ namespace GameControl.Server.Database.SprocTypes
public string ContentText { get; set; }
public Guid? AchievementUnlockId { get; set; }
public string ShortName { get; set; }
public string FileName { get; set; }

Просмотреть файл

@ -23,5 +23,7 @@ namespace GameControl.Server.Database.Tables
public DateTime LastUpdate { get; set; }
public byte[] EncryptionKey { get; set; }
public Guid? AchievementUnlockId { get; set; }
}
}

Просмотреть файл

@ -14,5 +14,7 @@ namespace GameControl.Server.RequestTypes
public string StringContent { get; set; }
public IFormFile BinaryContent { get; set; }
public Guid? AchievementUnlockId { get; set; }
}
}

Просмотреть файл

@ -31,7 +31,8 @@ namespace GameControl.Server.Util
ContentType = content.ContentType,
Name = content.ContentName,
ContentText = content.StringContent,
LastUpdate = DateTime.UtcNow
LastUpdate = DateTime.UtcNow,
AchievementUnlockId = content.AchievementUnlockId,
};
dbContext.AdditionalContent.Add(newContent);
@ -48,6 +49,7 @@ namespace GameControl.Server.Util
ContentType = content.ContentType,
Name = content.ContentName,
LastUpdate = DateTime.UtcNow,
AchievementUnlockId = content.AchievementUnlockId,
};
var targetPath =

Просмотреть файл

@ -1,6 +1,7 @@
using GameControl.Server.Database.SprocTypes;
using GameControl.Server.Database.Tables;
using GameControl.Server.Util;
using Microsoft.EntityFrameworkCore.Query.Internal;
using System;
namespace GameControl.Server.ViewModel
@ -18,6 +19,7 @@ namespace GameControl.Server.ViewModel
this.ContentType = source.ContentType.Trim();
this.Name = source.Name;
this.LastUpdated = source.LastUpdate;
this.AchievementUnlockId = source.AchievementUnlockId;
if (source.ContentType.Trim().Equals("PlainText", StringComparison.InvariantCultureIgnoreCase) ||
source.ContentType.Trim().Equals("RichText", StringComparison.InvariantCultureIgnoreCase) ||
@ -51,6 +53,7 @@ namespace GameControl.Server.ViewModel
this.ContentType = source.ContentType.Trim();
this.Name = source.ContentName;
this.LastUpdated = source.ContentLastUpdated.Value;
this.AchievementUnlockId = source.AchievementUnlockId;
if (source.ContentType.Trim().Equals("PlainText", StringComparison.InvariantCultureIgnoreCase) ||
source.ContentType.Trim().Equals("RichText", StringComparison.InvariantCultureIgnoreCase) ||
@ -96,6 +99,7 @@ namespace GameControl.Server.ViewModel
this.ContentType = source.ContentType.Trim();
this.Name = source.ContentName;
this.LastUpdated = source.ContentLastUpdated.Value;
this.AchievementUnlockId = source.AchievementUnlockId;
if (source.ContentType.Trim().Equals("PlainText", StringComparison.InvariantCultureIgnoreCase) ||
source.ContentType.Trim().Equals("RichText", StringComparison.InvariantCultureIgnoreCase) ||
@ -151,5 +155,7 @@ namespace GameControl.Server.ViewModel
public int LocationFlags { get; set; }
public DateTime LastUpdated { get; set; }
public Guid? AchievementUnlockId { get; set; }
}
}