Compare commits

...

16 Commits

Author SHA1 Message Date
YouHaveTrouble 4ed35ad807 there are 4 ticks per second, so 0.25 per second base gives 1 point per second. this is to simplify game balance calculations 2025-06-18 19:37:41 +02:00
YouHaveTrouble 89d8396c2a normalize quest completion check 2025-06-18 19:36:55 +02:00
YouHaveTrouble aa33c5b9e0 display correct percentage quest completion again 2025-06-18 19:36:34 +02:00
YouHaveTrouble b18e844ed1 simplify percent progress calculation 2025-06-18 19:35:47 +02:00
YouHaveTrouble d7728bd36b quests need an id 2025-06-18 19:35:15 +02:00
YouHaveTrouble 9a2900c50e only tick one phase at the time 2025-06-18 19:34:54 +02:00
YouHaveTrouble 3972c433bd drop all saved quests if they don't have phases 2025-06-18 19:34:42 +02:00
YouHaveTrouble e321263459 fix creating missives 2025-06-18 15:52:00 +02:00
YouHaveTrouble c836eb4632 switch quest logic to work with phase system 2025-06-16 20:26:05 +02:00
YouHaveTrouble 96eb036a8e somewhat closer to the final product quest phase system 2025-06-15 18:12:41 +02:00
YouHaveTrouble 5274835279 rough draft for quest phase system 2025-06-14 21:34:57 +02:00
YouHaveTrouble b3a0d51ef8 don't let player dismiss adventurers before they hire at least one 2025-06-03 07:46:17 +02:00
YouHaveTrouble d625fa7eee fix infinite loading screen on new save file 2025-06-03 07:43:31 +02:00
YouHaveTrouble 463fc90c9a make text in changelogs selectable 2025-06-03 00:07:30 +02:00
YouHaveTrouble 7da3e91af2 bump version 2025-06-02 23:38:23 +02:00
YouHaveTrouble 03c16126da fix hire button ignoring adventurer cap 2025-06-02 23:37:28 +02:00
11 changed files with 249 additions and 52 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "adventurers-guild",
"version": "0.15.0",
"version": "0.15.4",
"private": true,
"scripts": {
"dev": "vite",
+39 -17
View File
@@ -66,7 +66,7 @@ import {version} from "@/../package.json";
<script lang="ts">
import {defineComponent} from "vue";
import {Adventurer} from "@/classes/Adventurer";
import {getQuestWithRewards, Quest} from "@/classes/Quest";
import {getQuestWithRewards, Quest} from "@/classes/quests/Quest";
import {Guild} from "@/classes/Guild";
import {getFromString, QuestRank} from "@/classes/QuestRank";
import {
@@ -84,6 +84,8 @@ import QuestExpUpgrade from "@/classes/guildUpgrades/QuestExpUpgrade";
import QuestGoldUpgrade from "@/classes/guildUpgrades/QuestGoldUpgrade";
import AutoFinishQuestsUpgrade from "@/classes/guildUpgrades/AutoFinishQuestsUpgrade";
import RecruitmentCapacityUpgrade from "@/classes/guildUpgrades/RecruitmentCapacityUpgrade";
import QuestPhase from "@/classes/quests/QuestPhase";
import {PhaseType} from "@/classes/quests/QuestPhaseType";
export default defineComponent({
name: "GuildView",
@@ -121,23 +123,23 @@ export default defineComponent({
async updateMissives() {
for (const missive of this.missives) {
if (missive.adventurers.length < missive.maxAdventurers) {
missive.progressPoints = 0;
missive.phases.forEach(phase => {
phase.points = 0;
});
continue;
}
for (const adventurerId in missive.adventurers) {
const adventurer = missive.adventurers[adventurerId];
const attack = adventurer.getAttack();
adventurer.busy = true;
missive.progressPoints = Math.min(missive.progressPoints + attack, missive.maxProgress);
for (const phase of missive.phases) {
if (phase.completed()) continue;
phase.tick(missive.adventurers);
break;
}
if (
missive.progressPoints >= missive.maxProgress
missive.isCompleted()
&& this.guild.autoFinishQuestsUpgrade.getRanksToAutoFinishQuestsIn().includes(missive.rank)
) {
this.finalizeQuest(missive);
}
}
},
async checkForNewRecruit(currentTimestamp: number, cooldownModifier: number = 1) {
const recruitCapacity = this.guild.recruitmentCapacity.getRecruitmentCapacity();
@@ -148,8 +150,10 @@ export default defineComponent({
if (deltaTime > 0) return; // not yet time for a new recruit
if (Object.keys(this.adventurers).length <= 0) {
const firstAdventurer = this.adventurersDatabase[0];
const firstAdventurer = this.adventurersDatabase["aldek"];
this.adventurersForHire[firstAdventurer.id] = firstAdventurer;
this.setNextRecruitArrival(currentTimestamp, cooldownModifier)
return;
}
const newAdventurerForHire = getNewAdventurerForHire(Object.values(this.adventurersDatabase), Object.values(this.adventurers));
@@ -176,7 +180,6 @@ export default defineComponent({
delete this.adventurersForHire[adventurer.id];
},
finalizeQuest(missive: Quest) {
missive.progressPoints = 0;
this.guild.gold += missive.goldReward;
for (const adventurerId in missive.adventurers) {
const adventurer = missive.adventurers[adventurerId];
@@ -198,7 +201,7 @@ export default defineComponent({
return getQuestWithRewards(questsForRank[randomIdString], this.guild.expModifier.getModifier(), this.guild.goldModifier.getModifier());
},
createMissive(questToAdd: Quest) {
const quest = JSON.parse(JSON.stringify(questToAdd));
const quest = Quest.deserialize(questToAdd.serialize());
quest.id = Math.random().toString(16).slice(2).toString();
this.missives.push(quest);
},
@@ -255,9 +258,28 @@ export default defineComponent({
if (Array.isArray(saveData.missives)) {
for (const data of saveData.missives) {
const quest = new Quest(data.id, getFromString(data.rank), data.title, data.text, data.maxProgress, data.expReward, data.goldReward);
quest.progressPoints = data.progressPoints;
if (data.adventurers.length > 0) {
const phases: Array<QuestPhase> = [];
if (Array.isArray(data.phases) && data.phases.length > 0) {
for (const phaseData of data.phases) {
const types: Array<PhaseType> = [];
if (Array.isArray(phaseData.types)) {
for (const type of phaseData.types) {
try {
const phaseType = PhaseType[type as keyof typeof PhaseType];
types.push(phaseType);
} catch (e) {
console.error("Error creating phase type", e);
}
}
}
const phase = new QuestPhase(types, phaseData.maxPoints, phaseData.points);
phases.push(phase);
}
} else {
continue; // skip this missive if it has no phases
}
const quest = new Quest(data.id, getFromString(data.rank), data.title, data.text, phases, data.expReward, data.goldReward);
if (Array.isArray(data?.adventurers)) {
for (const adventurer of data.adventurers) {
const adventurerId = adventurer.id;
if (this.adventurers[adventurerId] == null) continue;
@@ -338,11 +360,11 @@ export default defineComponent({
this.loadGame();
this.adventurersDatabase = removeAlreadyHiredAdventurers(this.adventurersDatabase, this.adventurers);
if (Object.keys(this.adventurersForHire).length < this.guild.recruitmentCapacity.getRecruitmentCapacity()) {
if (Object.keys(this.adventurers).length > 0 && Object.keys(this.adventurersForHire).length < this.guild.recruitmentCapacity.getRecruitmentCapacity()) {
// check if more time passed than next recruit arrival and simulate next recruit arrivals up to now
const now = new Date();
if (this.nextRecruitArrival.getTime() < now.getTime()) {
const slotsLeft = 2 - Object.keys(this.adventurersForHire).length;
const slotsLeft = this.guild.recruitmentCapacity.getRecruitmentCapacity() - Object.keys(this.adventurersForHire).length;
for (let i = 0; i < slotsLeft; i++) {
await this.checkForNewRecruit(this.nextRecruitArrival.getTime());
}
+37 -2
View File
@@ -1,7 +1,9 @@
import {Guild} from "@/classes/Guild";
import {Adventurer} from "@/classes/Adventurer";
import {Quest} from "@/classes/Quest";
import {Quest} from "@/classes/quests/Quest";
import {getFromString, QuestRank} from "@/classes/QuestRank";
import QuestPhase from "@/classes/quests/QuestPhase";
import {PhaseType} from "@/classes/quests/QuestPhaseType";
export class GameData {
guild: Guild;
@@ -97,12 +99,37 @@ export async function loadAvailableQuests(): Promise<{ [key: string]: { [key: st
const questRankData = questsData.ranks[questRank];
for (const quest of questRankData) {
const id = quest.id;
const id = hash(JSON.stringify(quest));
const phases = [] as Array<QuestPhase>;
if (Array.isArray(quest?.phases)) {
for (const phase of quest.phases) {
const phaseTypes: PhaseType[] = [];
if (Array.isArray(phase?.types)) {
for (const type of phase.types) {
const phaseType = PhaseType[type as keyof typeof PhaseType];
if (!phaseType) {
console.error(`Invalid phase type: ${type}`);
continue;
}
phaseTypes.push(phaseType);
}
}
phases.push(new QuestPhase(
phaseTypes,
phase.maxPoints,
0,
));
}
}
quests[questRank][id] = new Quest(
id,
questRank,
quest.title,
quest.text,
phases,
);
}
}
@@ -144,3 +171,11 @@ export function removeAlreadyHiredAdventurers(
}
return adventurersForHire;
}
function hash(str: string): string {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return (hash >>> 0).toString(16);
}
@@ -1,5 +1,6 @@
import type {Adventurer} from "@/classes/Adventurer";
import {QuestRank} from "@/classes/QuestRank";
import QuestPhase from "@/classes/quests/QuestPhase";
export class Quest {
id: string;
@@ -7,9 +8,8 @@ export class Quest {
title: string;
text: string;
adventurers: Array<Adventurer>;
phases: QuestPhase[] = [];
maxAdventurers: number;
progressPoints: number;
maxProgress: number;
expReward: number;
goldReward: number;
@@ -18,7 +18,7 @@ export class Quest {
rank: QuestRank,
title: string,
text: string,
maxProgress: number = 1,
phases: QuestPhase[],
expReward: number = 0,
goldReward: number = 0,
maxAdventurers: number = 1
@@ -27,16 +27,75 @@ export class Quest {
this.rank = rank;
this.title = title;
this.text = text;
this.maxProgress = maxProgress;
this.expReward = expReward;
this.goldReward = goldReward;
this.progressPoints = 0;
this.adventurers = [];
this.maxAdventurers = maxAdventurers;
for (const phase of phases) {
this.phases.push(new QuestPhase(Array.from(phase.types), phase.maxPoints, phase.points));
}
}
getPercentProgress(): number {
return Math.round(this.progressPoints / this.maxProgress * 100);
let maxProgress = this.getMaxProgress();
let progressPoints = this.getProgress();
if (maxProgress === 0) return 0;
return progressPoints / maxProgress * 100;
}
isCompleted(): boolean {
for (const phase of this.phases) {
if (!phase.completed()) return false;
}
return true;
}
getMaxProgress(): number {
let maxProgress = 0;
for (const phase of this.phases) {
maxProgress += phase.maxPoints;
}
return maxProgress;
}
getProgress(): number {
let progressPoints = 0;
for (const phase of this.phases) {
progressPoints += phase.points;
}
return progressPoints;
}
serialize(): {[key: string]: any} {
return {
id: this.id,
rank: this.rank,
title: this.title,
text: this.text,
phases: this.phases.map(phase => phase.serialize()),
expReward: this.expReward,
goldReward: this.goldReward,
maxAdventurers: this.maxAdventurers,
};
}
static deserialize(data: {[key: string]: any}): Quest {
if (!data || typeof data !== 'object') {
throw new Error("Invalid data for Quest deserialization");
}
const phases = (data.phases || []).map((phaseData: any) => QuestPhase.deserialize(phaseData));
return new Quest(
data.id,
data.rank,
data.title,
data.text,
phases,
data.expReward || 0,
data.goldReward || 0,
data.maxAdventurers || 1
);
}
}
@@ -49,47 +108,47 @@ export class Quest {
*/
export function getQuestWithRewards(quest: Quest, expModifier: number = 1, goldModifier: number = 1) {
let maxProgress = 1;
let rewardValue = 1;
switch (quest.rank) {
case QuestRank.S:
// at level 30 adventurers have ~6513 dps, this will take 30 seconds on level 30
maxProgress = 195390;
rewardValue = 195390;
break;
case QuestRank.A:
// at level 25 adventurers have ~2051 dps, this will take 15 seconds on level 25
maxProgress = 30770;
rewardValue = 30770;
break;
case QuestRank.B:
// at level 20 adventurers have ~645 dps, this will take 15 seconds on level 20
maxProgress = 9690;
rewardValue = 9690;
break;
case QuestRank.C:
// at level 15 adventurers have ~203 dps, this will take 15 seconds on level 15
maxProgress = 3045;
rewardValue = 3045;
break;
case QuestRank.D:
// at level 10 adventurers have ~64 dps, this will take 15 seconds on level 10
maxProgress = 960;
rewardValue = 960;
break;
case QuestRank.E:
// at level 5 adventurers have ~20 dps, this will take 15 seconds on level 5
maxProgress = 300;
rewardValue = 300;
break;
case QuestRank.F:
// at level 1 adventurers have ~8 dps, this will take 15 seconds on level 1
maxProgress = 120;
rewardValue = 120;
break;
}
let goldReward = Math.floor(maxProgress/6 * goldModifier);
let expReward = Math.floor((Math.floor(maxProgress/120) - maxProgress/1000) * expModifier);
let goldReward = Math.floor(rewardValue/6 * goldModifier);
let expReward = Math.floor((Math.floor(rewardValue/120) - rewardValue/1000) * expModifier);
// add some randomness to the rewards
goldReward = Math.floor(randomNumberBetween(goldReward * 0.95, goldReward * 1.1));
expReward = Math.max(1, Math.floor(randomNumberBetween(expReward * 0.95, expReward * 1.2)));
return new Quest(quest.id, quest.rank, quest.title, quest.text, maxProgress, expReward, goldReward);
return new Quest(quest.id, quest.rank, quest.title, quest.text, quest.phases, expReward, goldReward);
}
function randomNumberBetween(min: number, max: number) {
+67
View File
@@ -0,0 +1,67 @@
import type {Adventurer} from "@/classes/Adventurer";
import {PhaseType} from "@/classes/quests/QuestPhaseType";
export default class QuestPhase {
types: Set<PhaseType>;
points: number;
maxPoints: number;
constructor(
types: PhaseType[],
maxPoints: number,
points: number = 0,
) {
this.types = new Set<PhaseType>(types);
this.points = Math.max(0, points);
this.maxPoints = Math.max(1, maxPoints);
}
/**
* Get how many points should be added each tick based on adventurers on a task.
*/
getPointIncrement(adventurers: Array<Adventurer>): number {
// TODO add point multiplier based on adventurer stats
return 0.25;
}
public tick(adventurers: Array<Adventurer> = []) {
if (this.completed()) return;
const pointsToAdd = this.getPointIncrement(adventurers);
this.points = Math.max(Math.min(this.points + pointsToAdd, this.maxPoints), 0);
}
public completed(): boolean {
return this.points >= this.maxPoints;
}
public serialize(): string {
return JSON.stringify({
types: Array.from(this.types),
points: this.points,
maxPoints: this.maxPoints,
});
}
public static deserialize(data: string): QuestPhase {
const parsedData = JSON.parse(data);
if (typeof parsedData?.points !== 'number') {
throw new Error("Invalid data: 'points' must be a number.");
}
if (typeof parsedData?.maxPoints !== 'number') {
throw new Error("Invalid data: 'maxPoints' must be a number.");
}
if (!Array.isArray(parsedData?.types)) {
throw new Error("Invalid data: 'types' must be an array.");
}
const types = parsedData.types.map((type: string) => PhaseType[type as keyof typeof PhaseType]);
return new QuestPhase(
types,
parsedData.maxPoints,
parsedData.points
);
}
}
+9
View File
@@ -0,0 +1,9 @@
export enum PhaseType {
TRAVEL = "travel",
FIGHT = "fight",
GATHER = "gather",
PHYSICAL = "physical",
MENTAL = "mental",
}
+8 -4
View File
@@ -19,7 +19,7 @@
</span>
<span
:title="Object.keys(adventurersForHire).length > 0 ? 'Dismiss' : ''"
:class="{disabled: Object.keys(adventurersForHire).length <= 0}"
:class="{disabled: Object.keys(adventurers).length <= 0}"
@click="dismissAdventurer(adventurerForHire)"
>
@@ -56,7 +56,7 @@ export default defineComponent({
return Object.values(this.adventurersForHire);
},
canRecruitMore() {
return Object.keys(this.adventurersForHire).length < this.guild.adventurerCapacity.getAdventurerCapacity();
return Object.keys(this.adventurers).length < this.guild.adventurerCapacity.getAdventurerCapacity();
},
newRecruitCost(): number {
const guildLevel = this.guild.level;
@@ -73,14 +73,14 @@ export default defineComponent({
this.$emit("hireAdventurer", adventurer);
},
dismissAdventurer(adventurer: Adventurer) {
if (Object.keys(this.adventurersForHire).length <= 0) return;
if (Object.keys(this.adventurers).length <= 0) return;
this.$emit("dismissAdventurer", adventurer);
},
previewAdventurer(adventurer: Adventurer): void {
this.$emit("previewAdventurer", adventurer);
},
findNewRecruit(): void {
if (!this.canRecruitMore) return;
if (this.recruitSlotsFilled) return;
this.$emit("findNewRecruit");
},
},
@@ -93,6 +93,10 @@ export default defineComponent({
type: Object as PropType<{ [key: string]: Adventurer }>,
required: true,
},
adventurers: {
type: Object as PropType<{ [key: string]: Adventurer }>,
required: true,
},
},
emits: ["dismissAdventurer", "hireAdventurer", "previewAdventurer", "findNewRecruit"],
})
+4 -4
View File
@@ -37,7 +37,7 @@
</div>
<div class="progressWrap">
<span class="progress"></span>
<span class="percentage">{{ `${progressPercentage.toFixed(2)}%` }}</span>
<span class="percentage">{{ `${missive.getPercentProgress().toFixed(2)}%` }}</span>
</div>
<h3>Rewards</h3>
<div class="rewards">
@@ -48,7 +48,7 @@
</template>
<script lang="ts">
import type {Quest} from "@/classes/Quest";
import type {Quest} from "@/classes/quests/Quest";
import AdventurerComponent from "@/components/AdventurerMiniComponent.vue";
import type {Adventurer} from "@/classes/Adventurer";
import {defineComponent, type PropType} from "vue";
@@ -61,7 +61,7 @@ export default defineComponent({
components: {Parchment, WaterStain, DrinkStain, AdventurerComponent},
computed: {
progressPercentageValue(): string {
return `${this.missive.progressPoints / this.missive.maxProgress * 100}%`;
return `${this.missive.getPercentProgress()}%`;
},
notBusyAdventurers(): Adventurer[] {
return Object.values(this.adventurers).filter(adventurer => !adventurer.busy);
@@ -94,7 +94,7 @@ export default defineComponent({
methods: {
updateProgress() {
if (this.missive === undefined) return;
this.progressPercentage = this.missive.progressPoints / this.missive.maxProgress * 100;
this.progressPercentage = this.missive.getPercentProgress();
},
randomNumber(min: number, max: number) {
return Math.random() * (max - min) + min;
@@ -91,7 +91,7 @@ export default defineComponent({
align-items: center;
gap: 1rem;
width: 100%;
overflow-y: auto;
user-select: text;
}
.changelog-entry {
+1
View File
@@ -9,6 +9,7 @@
<AdventurerRecruitment
:guild="guild"
:adventurers-for-hire="adventurersForHire"
:adventurers="adventurers"
@hireAdventurer="$emit('hireAdventurer', $event)"
@dismissAdventurer="$emit('dismissAdventurer', $event)"
@previewAdventurer="selectedAdventurer = $event"
+6 -6
View File
@@ -2,17 +2,17 @@
<section>
<QuestGroup
:adventurers="adventurers"
:quests="quests.filter(quest => quest.progressPoints < quest.maxProgress)"
:quests="quests.filter(quest => quest.getProgress() < quest.getMaxProgress())"
:finalizeQuest="finalizeQuest"
label="Quests"
v-show="quests.filter(quest => quest.progressPoints < quest.maxProgress).length > 0"
v-show="quests.filter(quest => quest.getProgress() < quest.getMaxProgress()).length > 0"
/>
<QuestGroup
:finalize-quest="finalizeQuest"
:adventurers="adventurers"
:quests="quests.filter(quest => quest.progressPoints >= quest.maxProgress)"
:quests="quests.filter(quest => quest.getProgress() >= quest.getMaxProgress())"
label="Completed Quests"
v-show="quests.filter(quest => quest.progressPoints >= quest.maxProgress).length > 0"
v-show="quests.filter(quest => quest.getProgress() >= quest.getMaxProgress()).length > 0"
/>
</section>
</template>
@@ -21,7 +21,7 @@
import {defineComponent, type PropType} from "vue";
import AdventurerComponent from "@/components/AdventurerMiniComponent.vue";
import type {Adventurer} from "@/classes/Adventurer";
import type {Quest} from "@/classes/Quest";
import type {Quest} from "@/classes/quests/Quest";
import {Guild} from "@/classes/Guild";
import QuestMissive from "@/components/QuestMissive.vue";
import QuestGroup from "@/components/QuestGroup.vue";
@@ -53,7 +53,7 @@ export default defineComponent({
methods: {
// This is a workaround for vue not reporting Quest as Quest in v-for
finalizeQuest(quest: any | Quest): void {
if (quest.progressPoints < quest.maxProgress) return;
if (!quest.isCompleted()) return;
this.$emit('finalizeQuest', quest)
},
}